diff options
242 files changed, 5569 insertions, 1154 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index b7040f924..7b10adbbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ Changelog All notable changes to this project will be documented in this file. +## [2.7.4] - 2019-03-05 +### Fixed + +- Fix web UI not cleaning up notifications after block ([Gargron](https://github.com/tootsuite/mastodon/pull/10108)) +- Fix redundant HTTP requests when resolving private statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10115)) +- Fix performance of account media query ([abcang](https://github.com/tootsuite/mastodon/pull/10121)) +- Fix mention processing for unknown accounts ([ThibG](https://github.com/tootsuite/mastodon/pull/10125)) +- Fix getting started column not scrolling on short screens ([trwnh](https://github.com/tootsuite/mastodon/pull/10075)) +- Fix direct messages pagination in the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10126)) +- Fix serialization of Announce activities ([ThibG](https://github.com/tootsuite/mastodon/pull/10129)) +- Fix home timeline perpetually reloading when empty in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10130)) +- Fix lists export ([ThibG](https://github.com/tootsuite/mastodon/pull/10136)) +- Fix edit profile page crash for suspended-then-unsuspended users ([ThibG](https://github.com/tootsuite/mastodon/pull/10178)) + ## [2.7.3] - 2019-02-23 ### Added diff --git a/Dockerfile b/Dockerfile index f4029a679..eead23833 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ RUN echo "Etc/UTC" > /etc/localtime && \ # Install jemalloc ENV JE_VER="5.1.0" -RUN apt -y install autoconf && \ +RUN apt update && \ + apt -y install autoconf && \ cd ~ && \ wget https://github.com/jemalloc/jemalloc/archive/$JE_VER.tar.gz && \ tar xf $JE_VER.tar.gz && \ @@ -33,7 +34,8 @@ RUN apt -y install autoconf && \ ENV RUBY_VER="2.6.1" ENV CPPFLAGS="-I/opt/jemalloc/include" ENV LDFLAGS="-L/opt/jemalloc/lib/" -RUN apt -y install build-essential \ +RUN apt update && \ + apt -y install build-essential \ bison libyaml-dev libgdbm-dev libreadline-dev \ libncurses5-dev libffi-dev zlib1g-dev libssl-dev && \ cd ~ && \ @@ -51,13 +53,14 @@ RUN apt -y install build-essential \ ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin" RUN npm install -g yarn && \ - gem install bundler + gem install bundler && \ + apt update && \ + apt -y install git libicu-dev libidn11-dev \ + libpq-dev libprotobuf-dev protobuf-compiler -COPY . /opt/mastodon +COPY Gemfile* package.json yarn.lock /opt/mastodon/ -RUN apt -y install git libicu-dev libidn11-dev \ - libpq-dev libprotobuf-dev protobuf-compiler && \ - cd /opt/mastodon && \ +RUN cd /opt/mastodon && \ bundle install -j$(nproc) --deployment --without development test && \ yarn install --pure-lockfile @@ -83,9 +86,6 @@ RUN apt update && \ useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \ echo "mastodon:`head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256`" | chpasswd -# Copy over masto source from building and set permissions -COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon - # Install masto runtime deps RUN apt -y --no-install-recommends install \ libssl1.1 libpq5 imagemagick ffmpeg \ @@ -93,11 +93,9 @@ RUN apt -y --no-install-recommends install \ file ca-certificates tzdata libreadline7 && \ apt -y install gcc && \ ln -s /opt/mastodon /mastodon && \ - gem install bundler - -# Clean up more dirs -RUN rm -rf /var/cache && \ - rm -rf /var/apt + gem install bundler && \ + rm -rf /var/cache && \ + rm -rf /var/lib/apt # Add tini ENV TINI_VERSION="0.18.0" @@ -106,6 +104,10 @@ ADD https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini /tin RUN echo "$TINI_SUM tini" | sha256sum -c - RUN chmod +x /tini +# Copy over masto source, and dependencies from building, and set permissions +COPY --chown=mastodon:mastodon . /opt/mastodon +COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon + # Run masto services in prod mode ENV RAILS_ENV="production" ENV NODE_ENV="production" diff --git a/Gemfile b/Gemfile index aeab2b832..5f40bd310 100644 --- a/Gemfile +++ b/Gemfile @@ -108,7 +108,7 @@ group :production, :test do end group :test do - gem 'capybara', '~> 3.13' + gem 'capybara', '~> 3.14' gem 'climate_control', '~> 0.2' gem 'faker', '~> 1.9' gem 'microformats', '~> 4.1' @@ -120,7 +120,7 @@ group :test do end group :development do - gem 'active_record_query_trace', '~> 1.5' + gem 'active_record_query_trace', '~> 1.6' gem 'annotate', '~> 2.7' gem 'better_errors', '~> 2.5' gem 'binding_of_caller', '~> 0.7' diff --git a/Gemfile.lock b/Gemfile.lock index 1c76025a4..684a34c0a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,7 +43,7 @@ GEM activemodel (>= 4.1, < 6) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - active_record_query_trace (1.5.4) + active_record_query_trace (1.6) activejob (5.2.2) activesupport (= 5.2.2) globalid (>= 0.3.6) @@ -126,7 +126,7 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (3.13.2) + capybara (3.14.0) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -243,7 +243,7 @@ GEM temple (>= 0.8.0) thor tilt - hamlit-rails (0.2.1) + hamlit-rails (0.2.2) actionpack (>= 4.0.1) activesupport (>= 4.0.1) hamlit (>= 1.2.0) @@ -402,7 +402,7 @@ GEM pg (1.1.4) pghero (2.2.0) activerecord - pkg-config (1.3.4) + pkg-config (1.3.5) powerpack (0.1.2) premailer (1.11.1) addressable @@ -567,7 +567,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.11) + sidekiq-unique-jobs (6.0.12) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (~> 0) @@ -643,7 +643,7 @@ GEM activesupport (>= 4.2) rack-proxy (>= 0.6.1) railties (>= 4.2) - webpush (0.3.6) + webpush (0.3.7) hkdf (~> 0.2) jwt (~> 2.0) websocket-driver (0.7.0) @@ -658,7 +658,7 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) - active_record_query_trace (~> 1.5) + active_record_query_trace (~> 1.6) addressable (~> 2.6) annotate (~> 2.7) aws-sdk-s3 (~> 1.30) @@ -673,7 +673,7 @@ DEPENDENCIES capistrano-rails (~> 1.4) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 3.13) + capybara (~> 3.14) charlock_holmes (~> 0.7.6) chewy (~> 5.0) cld3 (~> 3.2.3) diff --git a/Vagrantfile b/Vagrantfile index ada6fa4b2..f7195a9c1 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -44,7 +44,18 @@ sudo apt-get install \ # Install rvm read RUBY_VERSION < .ruby-version -gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + +gpg_command="gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB" +$($gpg_command) +if [ $? -ne 0 ];then + echo "GPG command failed, This prevented RVM from installing." + echo "Retrying once..." && $($gpg_command) + if [ $? -ne 0 ];then + echo "GPG failed for the second time, please ensure network connectivity." + echo "Exiting..." && exit 1 + fi +fi + curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION source /home/vagrant/.rvm/scripts/rvm diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb new file mode 100644 index 000000000..3fa0b6a76 --- /dev/null +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1::Polls::VotesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:statuses' } + before_action :require_user! + before_action :set_poll + + respond_to :json + + def create + VoteService.new.call(current_account, @poll, vote_params[:choices]) + render json: @poll, serializer: REST::PollSerializer + end + + private + + def set_poll + @poll = Poll.attached.find(params[:poll_id]) + authorize @poll.status, :show? + rescue Mastodon::NotPermittedError + raise ActiveRecord::RecordNotFound + end + + def vote_params + params.permit(choices: []) + end +end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb new file mode 100644 index 000000000..4f4a6858d --- /dev/null +++ b/app/controllers/api/v1/polls_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Api::V1::PollsController < Api::BaseController + before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show + + respond_to :json + + def show + @poll = Poll.attached.find(params[:id]) + ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale? + render json: @poll, serializer: REST::PollSerializer, include_results: true + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 29b420c67..f9506971a 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -53,6 +53,7 @@ class Api::V1::StatusesController < Api::BaseController visibility: status_params[:visibility], scheduled_at: status_params[:scheduled_at], application: doorkeeper_token.application, + poll: status_params[:poll], idempotency: request.headers['Idempotency-Key']) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer @@ -73,12 +74,25 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 instead of a 403 error code raise ActiveRecord::RecordNotFound end def status_params - params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, :scheduled_at, media_ids: []) + params.permit( + :status, + :in_reply_to_id, + :sensitive, + :spoiler_text, + :visibility, + :scheduled_at, + media_ids: [], + poll: [ + :multiple, + :hide_totals, + :expires_in, + options: [], + ] + ) end def pagination_params(core_params) diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 99c16157f..6f56a67ba 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -18,6 +18,7 @@ class StatusesController < ApplicationController before_action :redirect_to_original, only: [:show] before_action :set_referrer_policy_header, only: [:show] before_action :set_cache_headers + before_action :set_replies, only: [:replies] content_security_policy only: :embed do |p| p.frame_ancestors(false) @@ -65,8 +66,37 @@ class StatusesController < ApplicationController render 'stream_entries/embed', layout: 'embedded' end + def replies + skip_session! + + render json: replies_collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json', + skip_activities: true + end + private + def replies_collection_presenter + page = ActivityPub::CollectionPresenter.new( + id: replies_account_status_url(@account, @status, page_params), + type: :unordered, + part_of: replies_account_status_url(@account, @status), + next: next_page, + items: @replies.map { |status| status.local ? status : status.id } + ) + if page_requested? + page + else + ActivityPub::CollectionPresenter.new( + id: replies_account_status_url(@account, @status), + type: :unordered, + first: page + ) + end + end + def create_descendant_thread(starting_depth, statuses) depth = starting_depth + statuses.size if depth < DESCENDANTS_DEPTH_LIMIT @@ -176,4 +206,27 @@ class StatusesController < ApplicationController return if @status.public_visibility? || @status.unlisted_visibility? response.headers['Referrer-Policy'] = 'origin' end + + def page_requested? + params[:page] == 'true' + end + + def set_replies + @replies = page_params[:other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses + @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) + @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) + end + + def next_page + last_reply = @replies.last + return if last_reply.nil? + same_account = last_reply.account_id == @account.id + return unless same_account || @replies.size == DESCENDANTS_LIMIT + same_account = false unless @replies.size == DESCENDANTS_LIMIT + replies_account_status_url(@account, @status, page: true, min_id: last_reply.id, other_accounts: !same_account) + end + + def page_params + { page: true, other_accounts: params[:other_accounts], min_id: params[:min_id] }.compact + end end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 59e4ae685..f0a19e332 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -63,13 +63,19 @@ module JsonLdHelper json.present? && json['id'] == uri ? json : nil end - def fetch_resource_without_id_validation(uri, on_behalf_of = nil) + def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) build_request(uri, on_behalf_of).perform do |response| + unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response + end return body_to_json(response.body_with_limit) if response.code == 200 end # If request failed, retry without doing it on behalf of a user return if on_behalf_of.nil? build_request(uri).perform do |response| + unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response + end response.code == 200 ? body_to_json(response.body_with_limit) : nil end end @@ -92,6 +98,14 @@ module JsonLdHelper private + def response_successful?(response) + (200...300).cover?(response.code) + end + + def response_error_unsalvageable?(response) + (400...500).cover?(response.code) && response.code != 429 + end + def build_request(uri, on_behalf_of = nil) request = Request.new(:get, uri) request.on_behalf_of(on_behalf_of) if on_behalf_of diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index e2a303a77..1e49e4fc6 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -110,9 +110,19 @@ module StreamEntriesHelper I18n.t('statuses.content_warning', warning: status.spoiler_text) end + def poll_summary(status) + return unless status.poll + status.poll.options.map { |o| "[ ] #{o}" }.join("\n") + end + def status_description(status) components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')] - components << status.text if status.spoiler_text.blank? + + if status.spoiler_text.blank? + components << status.text + components << poll_summary(status) + end + components.reject(&:blank?).join("\n\n") end diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index d67ab112e..b659e4ff3 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -1,4 +1,5 @@ import api, { getLinks } from 'flavours/glitch/util/api'; +import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; @@ -94,7 +95,9 @@ export function fetchAccount(id) { dispatch(fetchAccountRequest(id)); api(getState).get(`/api/v1/accounts/${id}`).then(response => { - dispatch(fetchAccountSuccess(response.data)); + dispatch(importFetchedAccount(response.data)); + }).then(() => { + dispatch(fetchAccountSuccess()); }).catch(error => { dispatch(fetchAccountFail(id, error)); }); @@ -108,10 +111,9 @@ export function fetchAccountRequest(id) { }; }; -export function fetchAccountSuccess(account) { +export function fetchAccountSuccess() { return { type: ACCOUNT_FETCH_SUCCESS, - account, }; }; @@ -338,6 +340,7 @@ export function fetchFollowers(id) { api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -383,6 +386,7 @@ export function expandFollowers(id) { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -422,6 +426,7 @@ export function fetchFollowing(id) { api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -467,6 +472,7 @@ export function expandFollowing(id) { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -548,6 +554,7 @@ export function fetchFollowRequests() { api(getState).get('/api/v1/follow_requests').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); }).catch(error => dispatch(fetchFollowRequestsFail(error))); }; @@ -586,6 +593,7 @@ export function expandFollowRequests() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); }).catch(error => dispatch(expandFollowRequestsFail(error))); }; @@ -749,9 +757,10 @@ export function fetchPinnedAccounts() { return (dispatch, getState) => { dispatch(fetchPinnedAccountsRequest()); - api(getState).get(`/api/v1/endorsements`, { params: { limit: 0 } }) - .then(({ data }) => dispatch(fetchPinnedAccountsSuccess(data))) - .catch(err => dispatch(fetchPinnedAccountsFail(err))); + api(getState).get(`/api/v1/endorsements`, { params: { limit: 0 } }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuccess(response.data)); + }).catch(err => dispatch(fetchPinnedAccountsFail(err))); }; }; @@ -785,8 +794,10 @@ export function fetchPinnedAccountsSuggestions(q) { following: true, }; - api(getState).get('/api/v1/accounts/search', { params }) - .then(({ data }) => dispatch(fetchPinnedAccountsSuggestionsReady(q, data))); + api(getState).get('/api/v1/accounts/search', { params }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuggestionsReady(q, response.data)); + }); }; }; diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js index fe44ca19a..498ce519f 100644 --- a/app/javascript/flavours/glitch/actions/blocks.js +++ b/app/javascript/flavours/glitch/actions/blocks.js @@ -1,5 +1,6 @@ import api, { getLinks } from 'flavours/glitch/util/api'; import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; @@ -15,6 +16,7 @@ export function fetchBlocks() { api(getState).get('/api/v1/blocks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(fetchBlocksFail(error))); @@ -54,6 +56,7 @@ export function expandBlocks() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(expandBlocksFail(error))); diff --git a/app/javascript/flavours/glitch/actions/bookmarks.js b/app/javascript/flavours/glitch/actions/bookmarks.js index fb5d49ad3..83dbf5407 100644 --- a/app/javascript/flavours/glitch/actions/bookmarks.js +++ b/app/javascript/flavours/glitch/actions/bookmarks.js @@ -1,4 +1,5 @@ import api, { getLinks } from 'flavours/glitch/util/api'; +import { importFetchedStatuses } from './importer'; export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; @@ -18,6 +19,7 @@ export function fetchBookmarkedStatuses() { api(getState).get('/api/v1/bookmarks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchBookmarkedStatusesFail(error)); @@ -58,6 +60,7 @@ export function expandBookmarkedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandBookmarkedStatusesFail(error)); diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 0dd1766bc..ac09adceb 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -6,7 +6,7 @@ import { useEmoji } from './emojis'; import { tagHistory } from 'flavours/glitch/util/settings'; import { recoverHashtags } from 'flavours/glitch/util/hashtag'; import resizeImage from 'flavours/glitch/util/resize_image'; - +import { importFetchedAccounts } from './importer'; import { updateTimeline } from './timelines'; import { showAlertForError } from './alerts'; import { showAlert } from './alerts'; @@ -55,8 +55,16 @@ export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET'; +export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; +export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; +export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; +export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; +export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; +export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; + const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, }); export function changeCompose(text) { @@ -144,6 +152,7 @@ export function submitCompose(routerHistory) { sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), spoiler_text: spoilerText, visibility: getState().getIn(['compose', 'privacy']), + poll: getState().getIn(['compose', 'poll'], null), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -166,7 +175,9 @@ export function submitCompose(routerHistory) { // To make the app more responsive, immediately get the status into the columns const insertIfOnline = (timelineId) => { - if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { + const timeline = getState().getIn(['timelines', timelineId]); + + if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) { dispatch(updateTimeline(timelineId, { ...response.data })); } }; @@ -223,6 +234,12 @@ export function uploadCompose(files) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } + + if (getState().getIn(['compose', 'poll'])) { + dispatch(showAlert(undefined, messages.uploadErrorPoll)); + return; + } + dispatch(uploadComposeRequest()); for (const [i, f] of Array.from(files).entries()) { @@ -338,6 +355,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => limit: 4, }, }).then(response => { + dispatch(importFetchedAccounts(response.data)); dispatch(readyComposeSuggestionsAccounts(token, response.data)); }).catch(error => { if (!isCancel(error)) { @@ -503,3 +521,45 @@ export function insertEmojiCompose(position, emoji) { emoji, }; }; + +export function addPoll() { + return { + type: COMPOSE_POLL_ADD, + }; +}; + +export function removePoll() { + return { + type: COMPOSE_POLL_REMOVE, + }; +}; + +export function addPollOption(title) { + return { + type: COMPOSE_POLL_OPTION_ADD, + title, + }; +}; + +export function changePollOption(index, title) { + return { + type: COMPOSE_POLL_OPTION_CHANGE, + index, + title, + }; +}; + +export function removePollOption(index) { + return { + type: COMPOSE_POLL_OPTION_REMOVE, + index, + }; +}; + +export function changePollSettings(expiresIn, isMultiple) { + return { + type: COMPOSE_POLL_SETTINGS_CHANGE, + expiresIn, + isMultiple, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/favourites.js b/app/javascript/flavours/glitch/actions/favourites.js index 28eca8e5f..0d8bfb14d 100644 --- a/app/javascript/flavours/glitch/actions/favourites.js +++ b/app/javascript/flavours/glitch/actions/favourites.js @@ -1,4 +1,5 @@ import api, { getLinks } from 'flavours/glitch/util/api'; +import { importFetchedStatuses } from './importer'; export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; @@ -18,6 +19,7 @@ export function fetchFavouritedStatuses() { api(getState).get('/api/v1/favourites').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchFavouritedStatusesFail(error)); @@ -61,6 +63,7 @@ export function expandFavouritedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandFavouritedStatusesFail(error)); diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js new file mode 100644 index 000000000..f4372fb31 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -0,0 +1,90 @@ +import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer'; + +export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; +export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; +export const STATUS_IMPORT = 'STATUS_IMPORT'; +export const STATUSES_IMPORT = 'STATUSES_IMPORT'; +export const POLLS_IMPORT = 'POLLS_IMPORT'; + +function pushUnique(array, object) { + if (array.every(element => element.id !== object.id)) { + array.push(object); + } +} + +export function importAccount(account) { + return { type: ACCOUNT_IMPORT, account }; +} + +export function importAccounts(accounts) { + return { type: ACCOUNTS_IMPORT, accounts }; +} + +export function importStatus(status) { + return { type: STATUS_IMPORT, status }; +} + +export function importStatuses(statuses) { + return { type: STATUSES_IMPORT, statuses }; +} + +export function importPolls(polls) { + return { type: POLLS_IMPORT, polls }; +} + +export function importFetchedAccount(account) { + return importFetchedAccounts([account]); +} + +export function importFetchedAccounts(accounts) { + const normalAccounts = []; + + function processAccount(account) { + pushUnique(normalAccounts, normalizeAccount(account)); + + if (account.moved) { + processAccount(account.moved); + } + } + + accounts.forEach(processAccount); + + return importAccounts(normalAccounts); +} + +export function importFetchedStatus(status) { + return importFetchedStatuses([status]); +} + +export function importFetchedStatuses(statuses) { + return (dispatch, getState) => { + const accounts = []; + const normalStatuses = []; + const polls = []; + + function processStatus(status) { + pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); + pushUnique(accounts, status.account); + + if (status.reblog && status.reblog.id) { + processStatus(status.reblog); + } + + if (status.poll && status.poll.id) { + pushUnique(polls, normalizePoll(status.poll)); + } + } + + statuses.forEach(processStatus); + + dispatch(importPolls(polls)); + dispatch(importFetchedAccounts(accounts)); + dispatch(importStatuses(normalStatuses)); + }; +} + +export function importFetchedPoll(poll) { + return dispatch => { + dispatch(importPolls([normalizePoll(poll)])); + }; +} diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js new file mode 100644 index 000000000..ccd84364e --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -0,0 +1,78 @@ +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'flavours/glitch/util/emoji'; +import { unescapeHTML } from 'flavours/glitch/util/html'; +import { expandSpoilers } from 'flavours/glitch/util/initial_state'; + +const domParser = new DOMParser(); + +const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; +}, {}); + +export function normalizeAccount(account) { + account = { ...account }; + + const emojiMap = makeEmojiMap(account); + const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name; + + account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); + account.note_emojified = emojify(account.note, emojiMap); + + if (account.fields) { + account.fields = account.fields.map(pair => ({ + ...pair, + name_emojified: emojify(escapeTextContentForBrowser(pair.name)), + value_emojified: emojify(pair.value, emojiMap), + value_plain: unescapeHTML(pair.value), + })); + } + + if (account.moved) { + account.moved = account.moved.id; + } + + return account; +} + +export function normalizeStatus(status, normalOldStatus) { + const normalStatus = { ...status }; + normalStatus.account = status.account.id; + + if (status.reblog && status.reblog.id) { + normalStatus.reblog = status.reblog.id; + } + + if (status.poll && status.poll.id) { + normalStatus.poll = status.poll.id; + } + + // Only calculate these values when status first encountered + // Otherwise keep the ones already in the reducer + if (normalOldStatus) { + normalStatus.search_index = normalOldStatus.get('search_index'); + normalStatus.contentHtml = normalOldStatus.get('contentHtml'); + normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); + } else { + const spoilerText = normalStatus.spoiler_text || ''; + const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + const emojiMap = makeEmojiMap(normalStatus); + + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; + normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); + } + + return normalStatus; +} + +export function normalizePoll(poll) { + const normalPoll = { ...poll }; + + normalPoll.options = poll.options.map(option => ({ + ...option, + title_emojified: emojify(escapeTextContentForBrowser(option.title)), + })); + + return normalPoll; +} diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index edd8961f9..4407f8b6e 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -1,4 +1,5 @@ import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts, importFetchedStatus } from './importer'; export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -47,7 +48,8 @@ export function reblog(status) { api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { // The reblog API method returns a new status wrapped around the original. In this case we are only // interested in how the original is modified, hence passing it skipping the wrapper - dispatch(reblogSuccess(status, response.data.reblog)); + dispatch(importFetchedStatus(response.data.reblog)); + dispatch(reblogSuccess(status)); }).catch(function (error) { dispatch(reblogFail(status, error)); }); @@ -59,7 +61,8 @@ export function unreblog(status) { dispatch(unreblogRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { - dispatch(unreblogSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(unreblogSuccess(status)); }).catch(error => { dispatch(unreblogFail(status, error)); }); @@ -73,11 +76,10 @@ export function reblogRequest(status) { }; }; -export function reblogSuccess(status, response) { +export function reblogSuccess(status) { return { type: REBLOG_SUCCESS, status: status, - response: response, }; }; @@ -96,11 +98,10 @@ export function unreblogRequest(status) { }; }; -export function unreblogSuccess(status, response) { +export function unreblogSuccess(status) { return { type: UNREBLOG_SUCCESS, status: status, - response: response, }; }; @@ -117,7 +118,8 @@ export function favourite(status) { dispatch(favouriteRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { - dispatch(favouriteSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(favouriteSuccess(status)); }).catch(function (error) { dispatch(favouriteFail(status, error)); }); @@ -129,7 +131,8 @@ export function unfavourite(status) { dispatch(unfavouriteRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { - dispatch(unfavouriteSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(unfavouriteSuccess(status)); }).catch(error => { dispatch(unfavouriteFail(status, error)); }); @@ -143,11 +146,10 @@ export function favouriteRequest(status) { }; }; -export function favouriteSuccess(status, response) { +export function favouriteSuccess(status) { return { type: FAVOURITE_SUCCESS, status: status, - response: response, }; }; @@ -166,11 +168,10 @@ export function unfavouriteRequest(status) { }; }; -export function unfavouriteSuccess(status, response) { +export function unfavouriteSuccess(status) { return { type: UNFAVOURITE_SUCCESS, status: status, - response: response, }; }; @@ -187,7 +188,8 @@ export function bookmark(status) { dispatch(bookmarkRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { - dispatch(bookmarkSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(bookmarkSuccess(status)); }).catch(function (error) { dispatch(bookmarkFail(status, error)); }); @@ -199,7 +201,8 @@ export function unbookmark(status) { dispatch(unbookmarkRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { - dispatch(unbookmarkSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(unbookmarkSuccess(status)); }).catch(error => { dispatch(unbookmarkFail(status, error)); }); @@ -213,11 +216,10 @@ export function bookmarkRequest(status) { }; }; -export function bookmarkSuccess(status, response) { +export function bookmarkSuccess(status) { return { type: BOOKMARK_SUCCESS, status: status, - response: response, }; }; @@ -236,11 +238,10 @@ export function unbookmarkRequest(status) { }; }; -export function unbookmarkSuccess(status, response) { +export function unbookmarkSuccess(status) { return { type: UNBOOKMARK_SUCCESS, status: status, - response: response, }; }; @@ -257,6 +258,7 @@ export function fetchReblogs(id) { dispatch(fetchReblogsRequest(id)); api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); dispatch(fetchReblogsSuccess(id, response.data)); }).catch(error => { dispatch(fetchReblogsFail(id, error)); @@ -291,6 +293,7 @@ export function fetchFavourites(id) { dispatch(fetchFavouritesRequest(id)); api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFavouritesSuccess(id, response.data)); }).catch(error => { dispatch(fetchFavouritesFail(id, error)); @@ -325,7 +328,8 @@ export function pin(status) { dispatch(pinRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { - dispatch(pinSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(pinSuccess(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -339,11 +343,10 @@ export function pinRequest(status) { }; }; -export function pinSuccess(status, response) { +export function pinSuccess(status) { return { type: PIN_SUCCESS, status, - response, }; }; @@ -360,7 +363,8 @@ export function unpin (status) { dispatch(unpinRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { - dispatch(unpinSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(unpinSuccess(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); @@ -374,11 +378,10 @@ export function unpinRequest(status) { }; }; -export function unpinSuccess(status, response) { +export function unpinSuccess(status) { return { type: UNPIN_SUCCESS, status, - response, }; }; diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js index f29ca1e01..c2309b8c2 100644 --- a/app/javascript/flavours/glitch/actions/lists.js +++ b/app/javascript/flavours/glitch/actions/lists.js @@ -1,4 +1,5 @@ import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; import { showAlertForError } from './alerts'; export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; @@ -208,9 +209,10 @@ export const deleteListFail = (id, error) => ({ export const fetchListAccounts = listId => (dispatch, getState) => { dispatch(fetchListAccountsRequest(listId)); - api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }) - .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data))) - .catch(err => dispatch(fetchListAccountsFail(listId, err))); + api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListAccountsSuccess(listId, data)); + }).catch(err => dispatch(fetchListAccountsFail(listId, err))); }; export const fetchListAccountsRequest = id => ({ @@ -239,9 +241,10 @@ export const fetchListSuggestions = q => (dispatch, getState) => { following: true, }; - api(getState).get('/api/v1/accounts/search', { params }) - .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data))) - .catch(error => dispatch(showAlertForError(error))); + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); }; export const fetchListSuggestionsReady = (query, accounts) => ({ diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js index e06130533..927fc7415 100644 --- a/app/javascript/flavours/glitch/actions/mutes.js +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -1,5 +1,6 @@ import api, { getLinks } from 'flavours/glitch/util/api'; import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; import { openModal } from 'flavours/glitch/actions/modal'; export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; @@ -19,6 +20,7 @@ export function fetchMutes() { api(getState).get('/api/v1/mutes').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(fetchMutesFail(error))); @@ -58,6 +60,7 @@ export function expandMutes() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(expandMutesFail(error))); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 3cfad90a1..f89b4cb36 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -1,6 +1,12 @@ import api, { getLinks } from 'flavours/glitch/util/api'; import IntlMessageFormat from 'intl-messageformat'; import { fetchRelationships } from './accounts'; +import { + importFetchedAccount, + importFetchedAccounts, + importFetchedStatus, + importFetchedStatuses, +} from './importer'; import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from 'flavours/glitch/util/html'; @@ -47,9 +53,10 @@ const fetchRelatedRelationships = (dispatch, notifications) => { export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { - const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); - const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); - const filters = getFilters(getState(), { contextType: 'notifications' }); + const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + const filters = getFilters(getState(), { contextType: 'notifications' }); let filtered = false; @@ -60,15 +67,26 @@ export function updateNotifications(notification, intlMessages, intlLocale) { filtered = regex && regex.test(searchIndex); } - dispatch({ - type: NOTIFICATIONS_UPDATE, - notification, - account: notification.account, - status: notification.status, - meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, - }); + if (showInColumn) { + dispatch(importFetchedAccount(notification.account)); + + if (notification.status) { + dispatch(importFetchedStatus(notification.status)); + } + + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, + }); - fetchRelatedRelationships(dispatch, [notification]); + fetchRelatedRelationships(dispatch, [notification]); + } else if (playSound && !filtered) { + dispatch({ + type: NOTIFICATIONS_UPDATE_NOOP, + meta: { sound: 'boop' }, + }); + } // Desktop notifications if (typeof window.Notification !== 'undefined' && showAlert && !filtered) { @@ -120,6 +138,10 @@ export function expandNotifications({ maxId } = {}, done = noOp) { api(getState).get('/api/v1/notifications', { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); fetchRelatedRelationships(dispatch, response.data); done(); diff --git a/app/javascript/flavours/glitch/actions/pin_statuses.js b/app/javascript/flavours/glitch/actions/pin_statuses.js index d3d1a154f..77dfb9c7f 100644 --- a/app/javascript/flavours/glitch/actions/pin_statuses.js +++ b/app/javascript/flavours/glitch/actions/pin_statuses.js @@ -1,4 +1,5 @@ import api from 'flavours/glitch/util/api'; +import { importFetchedStatuses } from './importer'; export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; @@ -11,6 +12,7 @@ export function fetchPinnedStatuses() { dispatch(fetchPinnedStatusesRequest()); api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { + dispatch(importFetchedStatuses(response.data)); dispatch(fetchPinnedStatusesSuccess(response.data, null)); }).catch(error => { dispatch(fetchPinnedStatusesFail(error)); diff --git a/app/javascript/flavours/glitch/actions/polls.js b/app/javascript/flavours/glitch/actions/polls.js new file mode 100644 index 000000000..8e8b82df5 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/polls.js @@ -0,0 +1,60 @@ +import api from '../api'; +import { importFetchedPoll } from './importer'; + +export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; +export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; +export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; + +export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; +export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; +export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; + +export const vote = (pollId, choices) => (dispatch, getState) => { + dispatch(voteRequest()); + + api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(voteSuccess(data)); + }) + .catch(err => dispatch(voteFail(err))); +}; + +export const fetchPoll = pollId => (dispatch, getState) => { + dispatch(fetchPollRequest()); + + api(getState).get(`/api/v1/polls/${pollId}`) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(fetchPollSuccess(data)); + }) + .catch(err => dispatch(fetchPollFail(err))); +}; + +export const voteRequest = () => ({ + type: POLL_VOTE_REQUEST, +}); + +export const voteSuccess = poll => ({ + type: POLL_VOTE_SUCCESS, + poll, +}); + +export const voteFail = error => ({ + type: POLL_VOTE_FAIL, + error, +}); + +export const fetchPollRequest = () => ({ + type: POLL_FETCH_REQUEST, +}); + +export const fetchPollSuccess = poll => ({ + type: POLL_FETCH_SUCCESS, + poll, +}); + +export const fetchPollFail = error => ({ + type: POLL_FETCH_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js index ec65bdf28..bc094eed5 100644 --- a/app/javascript/flavours/glitch/actions/search.js +++ b/app/javascript/flavours/glitch/actions/search.js @@ -1,5 +1,6 @@ import api from 'flavours/glitch/util/api'; import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; export const SEARCH_CHANGE = 'SEARCH_CHANGE'; export const SEARCH_CLEAR = 'SEARCH_CLEAR'; @@ -38,6 +39,14 @@ export function submitSearch() { resolve: true, }, }).then(response => { + if (response.data.accounts) { + dispatch(importFetchedAccounts(response.data.accounts)); + } + + if (response.data.statuses) { + dispatch(importFetchedStatuses(response.data.statuses)); + } + dispatch(fetchSearchSuccess(response.data)); dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); }).catch(error => { diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 6183f3c03..4eabc4be0 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -1,6 +1,7 @@ import api from 'flavours/glitch/util/api'; import { deleteFromTimelines } from './timelines'; +import { importFetchedStatus, importFetchedStatuses } from './importer'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -45,17 +46,17 @@ export function fetchStatus(id) { dispatch(fetchStatusRequest(id, skipLoading)); api(getState).get(`/api/v1/statuses/${id}`).then(response => { - dispatch(fetchStatusSuccess(response.data, skipLoading)); + dispatch(importFetchedStatus(response.data)); + dispatch(fetchStatusSuccess(skipLoading)); }).catch(error => { dispatch(fetchStatusFail(id, error, skipLoading)); }); }; }; -export function fetchStatusSuccess(status, skipLoading) { +export function fetchStatusSuccess(skipLoading) { return { type: STATUS_FETCH_SUCCESS, - status, skipLoading, }; }; @@ -79,7 +80,11 @@ export function redraft(status) { export function deleteStatus(id, router, withRedraft = false) { return (dispatch, getState) => { - const status = getState().getIn(['statuses', id]); + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } dispatch(deleteStatusRequest(id)); @@ -127,6 +132,7 @@ export function fetchContext(id) { dispatch(fetchContextRequest(id)); api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { + dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants))); dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); }).catch(error => { diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js index 2dd94a998..34dcafc51 100644 --- a/app/javascript/flavours/glitch/actions/store.js +++ b/app/javascript/flavours/glitch/actions/store.js @@ -1,5 +1,6 @@ import { Iterable, fromJS } from 'immutable'; import { hydrateCompose } from './compose'; +import { importFetchedAccounts } from './importer'; export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; @@ -18,5 +19,6 @@ export function hydrateStore(rawState) { }); dispatch(hydrateCompose()); + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); }; }; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 8c1bd1f08..b5dd70989 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -3,6 +3,7 @@ import { updateTimeline, deleteFromTimelines, expandHomeTimeline, + connectTimeline, disconnectTimeline, } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; @@ -15,7 +16,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, return connectStream (path, pollingRefresh, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + }, + onDisconnect() { dispatch(disconnectTimeline(timelineId)); }, diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index bc21b4d5e..f218ee06b 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -1,3 +1,4 @@ +import { importFetchedStatus, importFetchedStatuses } from './importer'; import api, { getLinks } from 'flavours/glitch/util/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; @@ -11,14 +12,17 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export function updateTimeline(timeline, status, accept) { - return (dispatch, getState) => { + return dispatch => { if (typeof accept === 'function' && !accept(status)) { return; } + dispatch(importFetchedStatus(status)); + dispatch({ type: TIMELINE_UPDATE, timeline, @@ -77,6 +81,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); done(); }).catch(error => { @@ -141,6 +146,13 @@ export function scrollTopTimeline(timeline, top) { }; }; +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline, + }; +}; + export function disconnectTimeline(timeline) { return { type: TIMELINE_DISCONNECT, diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index dfbe75110..6a25794d3 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -107,6 +107,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} style={style} tabIndex={tabIndex} + disabled={disabled} > <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> </button> @@ -125,6 +126,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} style={style} tabIndex={tabIndex} + disabled={disabled} > <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> {this.props.label} diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js new file mode 100644 index 000000000..a1b297ce7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/poll.js @@ -0,0 +1,158 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { vote, fetchPoll } from 'mastodon/actions/polls'; +import Motion from 'mastodon/features/ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'mastodon/features/emoji/emoji'; + +const messages = defineMessages({ + moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, + seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, + minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, + hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, + days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, +}); + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const timeRemainingString = (intl, date, now) => { + const delta = date.getTime() - now; + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.moments); + } else if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); + } + + return relativeTime; +}; + +export default @injectIntl +class Poll extends ImmutablePureComponent { + + static propTypes = { + poll: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func, + disabled: PropTypes.bool, + }; + + state = { + selected: {}, + }; + + handleOptionChange = e => { + const { target: { value } } = e; + + if (this.props.poll.get('multiple')) { + const tmp = { ...this.state.selected }; + if (tmp[value]) { + delete tmp[value]; + } else { + tmp[value] = true; + } + this.setState({ selected: tmp }); + } else { + const tmp = {}; + tmp[value] = true; + this.setState({ selected: tmp }); + } + }; + + handleVote = () => { + if (this.props.disabled) { + return; + } + + this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected))); + }; + + handleRefresh = () => { + if (this.props.disabled) { + return; + } + + this.props.dispatch(fetchPoll(this.props.poll.get('id'))); + }; + + renderOption (option, optionIndex) { + const { poll, disabled } = this.props; + const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100; + const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); + const active = !!this.state.selected[`${optionIndex}`]; + const showResults = poll.get('voted') || poll.get('expired'); + + return ( + <li key={option.get('title')}> + {showResults && ( + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> + {({ width }) => + <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> + } + </Motion> + )} + + <label className={classNames('poll__text', { selectable: !showResults })}> + <input + name='vote-options' + type={poll.get('multiple') ? 'checkbox' : 'radio'} + value={optionIndex} + checked={active} + onChange={this.handleOptionChange} + disabled={disabled} + /> + + {!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />} + {showResults && <span className='poll__number'>{Math.round(percent)}%</span>} + + <span dangerouslySetInnerHTML={{ __html: option.get('title_emojified', emojify(escapeTextContentForBrowser(option.get('title')))) }} /> + </label> + </li> + ); + } + + render () { + const { poll, intl } = this.props; + + if (!poll) { + return null; + } + + const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now()); + const showResults = poll.get('voted') || poll.get('expired'); + const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); + + return ( + <div className='poll'> + <ul> + {poll.get('options').map((option, i) => this.renderOption(option, i))} + </ul> + + <div className='poll__footer'> + {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} + {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} + <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> + {poll.get('expires_at') && <span> · {timeRemaining}</span>} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 349f9c6cc..31f4f1ddd 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -15,6 +15,7 @@ import { HotKeys } from 'react-hotkeys'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; import classNames from 'classnames'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; +import PollContainer from 'flavours/glitch/containers/poll_container'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -437,7 +438,10 @@ export default class Status extends ImmutablePureComponent { // `media`, we snatch the thumbnail to use as our `background` if media // backgrounds for collapsed statuses are enabled. attachments = status.get('media_attachments'); - if (attachments.size > 0) { + if (status.get('poll')) { + media = <PollContainer pollId={status.get('poll')} />; + mediaIcon = 'tasks'; + } else if (attachments.size > 0) { if (muted || attachments.some(item => item.get('type') === 'unknown')) { media = ( <AttachmentList diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index c60d63f9a..98a34ebaf 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -100,8 +100,12 @@ export default class StatusContent extends React.PureComponent { const [ startX, startY ] = this.startXY; const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; - if (e.target.localName === 'button' || e.target.localName == 'video' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { - return; + let element = e.target; + while (element) { + if (element.localName === 'button' || element.localName === 'video' || element.localName === 'a' || element.localName === 'label') { + return; + } + element = element.parentNode; } if (deltaX + deltaY < 5 && e.button === 0 && parseClick) { diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js index c4b713e82..1b480658f 100644 --- a/app/javascript/flavours/glitch/containers/media_container.js +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -6,6 +6,7 @@ import { getLocale } from 'mastodon/locales'; import MediaGallery from 'flavours/glitch/components/media_gallery'; import Video from 'flavours/glitch/features/video'; import Card from 'flavours/glitch/features/status/components/card'; +import Poll from 'flavours/glitch/components/poll'; import ModalRoot from 'flavours/glitch/components/modal_root'; import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; import { List as ImmutableList, fromJS } from 'immutable'; @@ -13,7 +14,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; export default class MediaContainer extends PureComponent { @@ -54,11 +55,12 @@ export default class MediaContainer extends PureComponent { {[].map.call(components, (component, i) => { const componentName = component.getAttribute('data-component'); const Component = MEDIA_COMPONENTS[componentName]; - const { media, card, ...props } = JSON.parse(component.getAttribute('data-props')); + const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props')); Object.assign(props, { ...(media ? { media: fromJS(media) } : {}), ...(card ? { card: fromJS(card) } : {}), + ...(poll ? { poll: fromJS(poll) } : {}), ...(componentName === 'Video' ? { onOpenVideo: this.handleOpenVideo, diff --git a/app/javascript/flavours/glitch/containers/poll_container.js b/app/javascript/flavours/glitch/containers/poll_container.js new file mode 100644 index 000000000..cd7216de7 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/poll_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Poll from 'mastodon/components/poll'; + +const mapStateToProps = (state, { pollId }) => ({ + poll: state.getIn(['polls', pollId]), +}); + +export default connect(mapStateToProps)(Poll); diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js new file mode 100644 index 000000000..1a6abef37 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ColumnHeader from '../../../components/column_header'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + profile: { id: 'column_header.profile', defaultMessage: 'Profile' }, +}); + +export default @injectIntl +class ProfileColumnHeader extends React.PureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + }; + + render() { + const { intl } = this.props; + + return ( + <ColumnHeader + icon='user-circle' + title={intl.formatMessage(messages.profile)} + showBackButton + /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js index a5fa01444..a9ea5088e 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/index.js +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -6,7 +6,7 @@ import { fetchAccount } from 'flavours/glitch/actions/accounts'; import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import Column from 'flavours/glitch/features/ui/components/column'; -import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { getAccountGallery } from 'flavours/glitch/selectors'; import MediaItem from './components/media_item'; @@ -113,7 +113,7 @@ export default class AccountGallery extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ProfileColumnHeader /> <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index 6f887a145..415e3be20 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -7,8 +7,8 @@ import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/g import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import HeaderContainer from './containers/header_container'; -import ColumnBackButton from '../../components/column_back_button'; import { List as ImmutableList } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; @@ -74,7 +74,7 @@ export default class AccountTimeline extends ImmutablePureComponent { return ( <Column name='account'> - <ColumnBackButton /> + <ProfileColumnHeader /> <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js index ec0e405a4..9d2e0b3da 100644 --- a/app/javascript/flavours/glitch/features/composer/index.js +++ b/app/javascript/flavours/glitch/features/composer/index.js @@ -31,6 +31,7 @@ import { openModal, } from 'flavours/glitch/actions/modal'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; +import { addPoll, removePoll } from 'flavours/glitch/actions/compose'; // Components. import ComposerOptions from './options'; @@ -39,6 +40,7 @@ import ComposerReply from './reply'; import ComposerSpoiler from './spoiler'; import ComposerTextarea from './textarea'; import ComposerUploadForm from './upload_form'; +import ComposerPollForm from './poll_form'; import ComposerWarning from './warning'; import ComposerHashtagWarning from './hashtag_warning'; import ComposerDirectWarning from './direct_warning'; @@ -102,6 +104,7 @@ function mapStateToProps (state) { suggestions: state.getIn(['compose', 'suggestions']), text: state.getIn(['compose', 'text']), anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, + poll: state.getIn(['compose', 'poll']), spoilersAlwaysOn: spoilersAlwaysOn, mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), @@ -134,6 +137,15 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onChangeVisibility(value) { dispatch(changeComposeVisibility(value)); }, + onTogglePoll() { + dispatch((_, getState) => { + if (getState().getIn(['compose', 'poll'])) { + dispatch(removePoll()); + } else { + dispatch(addPoll()); + } + }); + }, onClearSuggestions() { dispatch(clearComposeSuggestions()); }, @@ -394,6 +406,7 @@ class Composer extends React.Component { isUploading, layout, media, + poll, onCancelReply, onChangeAdvancedOption, onChangeDescription, @@ -401,6 +414,7 @@ class Composer extends React.Component { onChangeSpoilerness, onChangeText, onChangeVisibility, + onTogglePoll, onClearSuggestions, onCloseModal, onFetchSuggestions, @@ -463,30 +477,38 @@ class Composer extends React.Component { suggestions={suggestions} value={text} /> - {isUploading || media && media.size ? ( - <ComposerUploadForm - intl={intl} - media={media} - onChangeDescription={onChangeDescription} - onOpenFocalPointModal={onOpenFocalPointModal} - onRemove={onUndoUpload} - progress={progress} - uploading={isUploading} - handleRef={handleRefUploadForm} - /> - ) : null} + <div className='compose-form__modifiers'> + {isUploading || media && media.size ? ( + <ComposerUploadForm + intl={intl} + media={media} + onChangeDescription={onChangeDescription} + onOpenFocalPointModal={onOpenFocalPointModal} + onRemove={onUndoUpload} + progress={progress} + uploading={isUploading} + handleRef={handleRefUploadForm} + /> + ) : null} + {!!poll && ( + <ComposerPollForm /> + )} + </div> <ComposerOptions acceptContentTypes={acceptContentTypes} advancedOptions={advancedOptions} disabled={isSubmitting} - full={media ? media.size >= 4 || media.some( - item => item.get('type') === 'video' - ) : false} + allowMedia={!poll && (media ? media.size < 4 && !media.some( + item => item.get('type') === 'video' + ) : true)} hasMedia={media && !!media.size} + allowPoll={!(media && !!media.size)} + hasPoll={!!poll} intl={intl} onChangeAdvancedOption={onChangeAdvancedOption} onChangeSensitivity={onChangeSensitivity} onChangeVisibility={onChangeVisibility} + onTogglePoll={onTogglePoll} onDoodleOpen={onOpenDoodleModal} onModalClose={onCloseModal} onModalOpen={onOpenActionsModal} diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js index 5b4a7444c..7c7f01dc2 100644 --- a/app/javascript/flavours/glitch/features/composer/options/index.js +++ b/app/javascript/flavours/glitch/features/composer/options/index.js @@ -19,6 +19,7 @@ import { assignHandlers, hiddenComponent, } from 'flavours/glitch/util/react_helpers'; +import { pollLimits } from 'flavours/glitch/util/initial_state'; // Messages. const messages = defineMessages({ @@ -98,6 +99,14 @@ const messages = defineMessages({ defaultMessage: 'Upload a file', id: 'compose.attach.upload', }, + add_poll: { + defaultMessage: 'Add a poll', + id: 'poll_button.add_poll', + }, + remove_poll: { + defaultMessage: 'Remove poll', + id: 'poll_button.remove_poll', + }, }); // Handlers. @@ -160,12 +169,15 @@ export default class ComposerOptions extends React.PureComponent { acceptContentTypes, advancedOptions, disabled, - full, + allowMedia, hasMedia, + allowPoll, + hasPoll, intl, onChangeAdvancedOption, onChangeSensitivity, onChangeVisibility, + onTogglePoll, onModalClose, onModalOpen, onToggleSpoiler, @@ -209,7 +221,7 @@ export default class ComposerOptions extends React.PureComponent { <div className='composer--options'> <input accept={acceptContentTypes} - disabled={disabled || full} + disabled={disabled || !allowMedia} key={resetFileKey} onChange={handleChangeFiles} ref={handleRefFileElement} @@ -218,7 +230,7 @@ export default class ComposerOptions extends React.PureComponent { {...hiddenComponent} /> <Dropdown - disabled={disabled || full} + disabled={disabled || !allowMedia} icon='paperclip' items={[ { @@ -237,6 +249,21 @@ export default class ComposerOptions extends React.PureComponent { onModalOpen={onModalOpen} title={intl.formatMessage(messages.attach)} /> + {!!pollLimits && ( + <IconButton + active={hasPoll} + disabled={disabled || !allowPoll} + icon='tasks' + inverted + onClick={onTogglePoll} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} + /> + )} <Motion defaultStyle={{ scale: 0.87 }} style={{ @@ -329,12 +356,15 @@ ComposerOptions.propTypes = { acceptContentTypes: PropTypes.string, advancedOptions: ImmutablePropTypes.map, disabled: PropTypes.bool, - full: PropTypes.bool, + allowMedia: PropTypes.bool, hasMedia: PropTypes.bool, + allowPoll: PropTypes.bool, + hasPoll: PropTypes.bool, intl: PropTypes.object.isRequired, onChangeAdvancedOption: PropTypes.func, onChangeSensitivity: PropTypes.func, onChangeVisibility: PropTypes.func, + onTogglePoll: PropTypes.func, onDoodleOpen: PropTypes.func, onModalClose: PropTypes.func, onModalOpen: PropTypes.func, diff --git a/app/javascript/flavours/glitch/features/composer/poll_form/components/poll_form.js b/app/javascript/flavours/glitch/features/composer/poll_form/components/poll_form.js new file mode 100644 index 000000000..7ee28e304 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/poll_form/components/poll_form.js @@ -0,0 +1,135 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; +import { pollLimits } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, + add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, + remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, + poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, + single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' }, + multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' }, + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, +}); + +@injectIntl +class Option extends React.PureComponent { + + static propTypes = { + title: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, + isPollMultiple: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleOptionTitleChange = e => { + this.props.onChange(this.props.index, e.target.value); + }; + + handleOptionRemove = () => { + this.props.onRemove(this.props.index); + }; + + render () { + const { isPollMultiple, title, index, intl } = this.props; + + return ( + <li> + <label className='poll__text editable'> + <span className={classNames('poll__input', { checkbox: isPollMultiple })} /> + + <input + type='text' + placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} + maxlength={pollLimits.max_option_chars} + value={title} + onChange={this.handleOptionTitleChange} + /> + </label> + + <div className='poll__cancel'> + <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} /> + </div> + </li> + ); + } + +} + +export default +@injectIntl +class PollForm extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.list, + expiresIn: PropTypes.number, + isMultiple: PropTypes.bool, + onChangeOption: PropTypes.func.isRequired, + onAddOption: PropTypes.func.isRequired, + onRemoveOption: PropTypes.func.isRequired, + onChangeSettings: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleAddOption = () => { + this.props.onAddOption(''); + }; + + handleSelectDuration = e => { + this.props.onChangeSettings(e.target.value, this.props.isMultiple); + }; + + handleSelectMultiple = e => { + this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true'); + }; + + render () { + const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props; + + if (!options) { + return null; + } + + return ( + <div className='compose-form__poll-wrapper'> + <ul> + {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} />)} + {options.size < pollLimits.max_options && ( + <label className='poll__text editable'> + <span className={classNames('poll__input')} style={{ opacity: 0 }} /> + <button className='button button-secondary' onClick={this.handleAddOption}><Icon icon='plus' /> <FormattedMessage {...messages.add_option} /></button> + </label> + )} + </ul> + + <div className='poll__footer'> + <select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}> + <option value='false'>{intl.formatMessage(messages.single_choice)}</option> + <option value='true'>{intl.formatMessage(messages.multiple_choices)}</option> + </select> + + <select value={expiresIn} onChange={this.handleSelectDuration}> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/composer/poll_form/index.js b/app/javascript/flavours/glitch/features/composer/poll_form/index.js new file mode 100644 index 000000000..5232c3b31 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/poll_form/index.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import PollForm from './components/poll_form'; +import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + options: state.getIn(['compose', 'poll', 'options']), + expiresIn: state.getIn(['compose', 'poll', 'expires_in']), + isMultiple: state.getIn(['compose', 'poll', 'multiple']), +}); + +const mapDispatchToProps = dispatch => ({ + onAddOption(title) { + dispatch(addPollOption(title)); + }, + + onRemoveOption(index) { + dispatch(removePollOption(index)); + }, + + onChangeOption(index, title) { + dispatch(changePollOption(index, title)); + }, + + onChangeSettings(expiresIn, isMultiple) { + dispatch(changePollSettings(expiresIn, isMultiple)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PollForm); diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js index a977142ed..124004cb6 100644 --- a/app/javascript/flavours/glitch/features/followers/index.js +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -11,9 +11,9 @@ import { import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Column from 'flavours/glitch/features/ui/components/column'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; import LoadMore from 'flavours/glitch/components/load_more'; -import ColumnBackButton from 'flavours/glitch/components/column_back_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; const mapStateToProps = (state, props) => ({ @@ -80,7 +80,7 @@ export default class Followers extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ProfileColumnHeader /> <ScrollContainer scrollKey='followers' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable' onScroll={this.handleScroll}> diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js index 70aeefaad..656100dad 100644 --- a/app/javascript/flavours/glitch/features/following/index.js +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -11,9 +11,9 @@ import { import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Column from 'flavours/glitch/features/ui/components/column'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; import LoadMore from 'flavours/glitch/components/load_more'; -import ColumnBackButton from 'flavours/glitch/components/column_back_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; const mapStateToProps = (state, props) => ({ @@ -80,7 +80,7 @@ export default class Following extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ProfileColumnHeader /> <ScrollContainer scrollKey='following' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable' onScroll={this.handleScroll}> diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js index 7d124ba01..8eb79fa60 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.js +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -16,7 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, - isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null, + isPartial: state.getIn(['timelines', 'home', 'isPartial']), }); @connect(mapStateToProps) diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index e405a5ef0..f974a87a1 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -75,7 +75,7 @@ export default class Card extends React.PureComponent { }; componentWillReceiveProps (nextProps) { - if (this.props.card !== nextProps.card) { + if (!Immutable.is(this.props.card, nextProps.card)) { this.setState({ embedded: false }); } } diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 120ae6817..ad60320ef 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -14,6 +14,7 @@ import Video from 'flavours/glitch/features/video'; import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import classNames from 'classnames'; +import PollContainer from 'flavours/glitch/containers/poll_container'; export default class DetailedStatus extends ImmutablePureComponent { @@ -118,7 +119,9 @@ export default class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } - if (status.get('media_attachments').size > 0) { + if (status.get('poll')) { + media = <PollContainer pollId={status.get('poll')} />; + } else if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = <AttachmentList media={status.get('media_attachments')} />; } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 86c4db283..880372de5 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -54,6 +54,7 @@ const messages = defineMessages({ detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + tootHeading: { id: 'column.toot', defaultMessage: 'Toots and replies' }, }); const makeMapStateToProps = () => { @@ -453,6 +454,8 @@ export default class Status extends ImmutablePureComponent { return ( <Column label={intl.formatMessage(messages.detailedStatus)}> <ColumnHeader + icon='comment' + title={intl.formatMessage(messages.tootHeading)} showBackButton extraButton={( <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><i className={`fa fa-${!isExpanded ? 'eye-slash' : 'eye'}`} /></button> diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js index 860c13534..530ed8e60 100644 --- a/app/javascript/flavours/glitch/reducers/accounts.js +++ b/app/javascript/flavours/glitch/reducers/accounts.js @@ -1,68 +1,7 @@ -import { - ACCOUNT_FETCH_SUCCESS, - FOLLOWERS_FETCH_SUCCESS, - FOLLOWERS_EXPAND_SUCCESS, - FOLLOWING_FETCH_SUCCESS, - FOLLOWING_EXPAND_SUCCESS, - FOLLOW_REQUESTS_FETCH_SUCCESS, - FOLLOW_REQUESTS_EXPAND_SUCCESS, - PINNED_ACCOUNTS_FETCH_SUCCESS, - PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY, -} from 'flavours/glitch/actions/accounts'; -import { - BLOCKS_FETCH_SUCCESS, - BLOCKS_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/blocks'; -import { - MUTES_FETCH_SUCCESS, - MUTES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/mutes'; -import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose'; -import { - REBLOG_SUCCESS, - UNREBLOG_SUCCESS, - FAVOURITE_SUCCESS, - UNFAVOURITE_SUCCESS, - BOOKMARK_SUCCESS, - UNBOOKMARK_SUCCESS, - REBLOGS_FETCH_SUCCESS, - FAVOURITES_FETCH_SUCCESS, -} from 'flavours/glitch/actions/interactions'; -import { - TIMELINE_UPDATE, - TIMELINE_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/timelines'; -import { - STATUS_FETCH_SUCCESS, - CONTEXT_FETCH_SUCCESS, -} from 'flavours/glitch/actions/statuses'; -import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search'; -import { - NOTIFICATIONS_UPDATE, - NOTIFICATIONS_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/notifications'; -import { - FAVOURITED_STATUSES_FETCH_SUCCESS, - FAVOURITED_STATUSES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/favourites'; -import { - BOOKMARKED_STATUSES_FETCH_SUCCESS, - BOOKMARKED_STATUSES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/bookmarks'; -import { - LIST_ACCOUNTS_FETCH_SUCCESS, - LIST_EDITOR_SUGGESTIONS_READY, -} from 'flavours/glitch/actions/lists'; -import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; -import emojify from 'flavours/glitch/util/emoji'; +import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; -import escapeTextContentForBrowser from 'escape-html'; -import { unescapeHTML } from 'flavours/glitch/util/html'; -const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji; - return obj; -}, {}); +const initialState = ImmutableMap(); const normalizeAccount = (state, account) => { account = { ...account }; @@ -71,25 +10,6 @@ const normalizeAccount = (state, account) => { delete account.following_count; delete account.statuses_count; - const emojiMap = makeEmojiMap(account); - const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name; - account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); - account.note_emojified = emojify(account.note, emojiMap); - - if (account.fields) { - account.fields = account.fields.map(pair => ({ - ...pair, - name_emojified: emojify(escapeTextContentForBrowser(pair.name)), - value_emojified: emojify(pair.value, emojiMap), - value_plain: unescapeHTML(pair.value), - })); - } - - if (account.moved) { - state = normalizeAccount(state, account.moved); - account.moved = account.moved.id; - } - return state.set(account.id, fromJS(account)); }; @@ -101,71 +21,12 @@ const normalizeAccounts = (state, accounts) => { return state; }; -const normalizeAccountFromStatus = (state, status) => { - state = normalizeAccount(state, status.account); - - if (status.reblog && status.reblog.account) { - state = normalizeAccount(state, status.reblog.account); - } - - return state; -}; - -const normalizeAccountsFromStatuses = (state, statuses) => { - statuses.forEach(status => { - state = normalizeAccountFromStatus(state, status); - }); - - return state; -}; - -const initialState = ImmutableMap(); - export default function accounts(state = initialState, action) { switch(action.type) { - case STORE_HYDRATE: - return normalizeAccounts(state, Object.values(action.state.get('accounts').toJS())); - case ACCOUNT_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: + case ACCOUNT_IMPORT: return normalizeAccount(state, action.account); - case FOLLOWERS_FETCH_SUCCESS: - case FOLLOWERS_EXPAND_SUCCESS: - case FOLLOWING_FETCH_SUCCESS: - case FOLLOWING_EXPAND_SUCCESS: - case REBLOGS_FETCH_SUCCESS: - case FAVOURITES_FETCH_SUCCESS: - case COMPOSE_SUGGESTIONS_READY: - case FOLLOW_REQUESTS_FETCH_SUCCESS: - case FOLLOW_REQUESTS_EXPAND_SUCCESS: - case BLOCKS_FETCH_SUCCESS: - case BLOCKS_EXPAND_SUCCESS: - case MUTES_FETCH_SUCCESS: - case MUTES_EXPAND_SUCCESS: - case LIST_ACCOUNTS_FETCH_SUCCESS: - case LIST_EDITOR_SUGGESTIONS_READY: - case PINNED_ACCOUNTS_FETCH_SUCCESS: - case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY: - return action.accounts ? normalizeAccounts(state, action.accounts) : state; - case NOTIFICATIONS_EXPAND_SUCCESS: - case SEARCH_FETCH_SUCCESS: - return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); - case TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - case FAVOURITED_STATUSES_FETCH_SUCCESS: - case FAVOURITED_STATUSES_EXPAND_SUCCESS: - case BOOKMARKED_STATUSES_FETCH_SUCCESS: - case BOOKMARKED_STATUSES_EXPAND_SUCCESS: - return normalizeAccountsFromStatuses(state, action.statuses); - case REBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNREBLOG_SUCCESS: - case UNFAVOURITE_SUCCESS: - case BOOKMARK_SUCCESS: - case UNBOOKMARK_SUCCESS: - return normalizeAccountFromStatus(state, action.response); - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - return normalizeAccountFromStatus(state, action.status); + case ACCOUNTS_IMPORT: + return normalizeAccounts(state, action.accounts); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/accounts_counters.js b/app/javascript/flavours/glitch/reducers/accounts_counters.js index acf363ca5..9ebf72af9 100644 --- a/app/javascript/flavours/glitch/reducers/accounts_counters.js +++ b/app/javascript/flavours/glitch/reducers/accounts_counters.js @@ -1,59 +1,8 @@ import { - ACCOUNT_FETCH_SUCCESS, - FOLLOWERS_FETCH_SUCCESS, - FOLLOWERS_EXPAND_SUCCESS, - FOLLOWING_FETCH_SUCCESS, - FOLLOWING_EXPAND_SUCCESS, - FOLLOW_REQUESTS_FETCH_SUCCESS, - FOLLOW_REQUESTS_EXPAND_SUCCESS, ACCOUNT_FOLLOW_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS, -} from 'flavours/glitch/actions/accounts'; -import { - BLOCKS_FETCH_SUCCESS, - BLOCKS_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/blocks'; -import { - MUTES_FETCH_SUCCESS, - MUTES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/mutes'; -import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose'; -import { - REBLOG_SUCCESS, - UNREBLOG_SUCCESS, - FAVOURITE_SUCCESS, - UNFAVOURITE_SUCCESS, - BOOKMARK_SUCCESS, - UNBOOKMARK_SUCCESS, - REBLOGS_FETCH_SUCCESS, - FAVOURITES_FETCH_SUCCESS, -} from 'flavours/glitch/actions/interactions'; -import { - TIMELINE_UPDATE, - TIMELINE_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/timelines'; -import { - STATUS_FETCH_SUCCESS, - CONTEXT_FETCH_SUCCESS, -} from 'flavours/glitch/actions/statuses'; -import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search'; -import { - NOTIFICATIONS_UPDATE, - NOTIFICATIONS_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/notifications'; -import { - FAVOURITED_STATUSES_FETCH_SUCCESS, - FAVOURITED_STATUSES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/favourites'; -import { - BOOKMARKED_STATUSES_FETCH_SUCCESS, - BOOKMARKED_STATUSES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/bookmarks'; -import { - LIST_ACCOUNTS_FETCH_SUCCESS, - LIST_EDITOR_SUGGESTIONS_READY, -} from 'flavours/glitch/actions/lists'; -import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +} from '../actions/accounts'; +import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; const normalizeAccount = (state, account) => state.set(account.id, fromJS({ @@ -70,80 +19,19 @@ const normalizeAccounts = (state, accounts) => { return state; }; -const normalizeAccountFromStatus = (state, status) => { - state = normalizeAccount(state, status.account); - - if (status.reblog && status.reblog.account) { - state = normalizeAccount(state, status.reblog.account); - } - - return state; -}; - -const normalizeAccountsFromStatuses = (state, statuses) => { - statuses.forEach(status => { - state = normalizeAccountFromStatus(state, status); - }); - - return state; -}; - const initialState = ImmutableMap(); export default function accountsCounters(state = initialState, action) { switch(action.type) { - case STORE_HYDRATE: - return state.merge(action.state.get('accounts').map(item => fromJS({ - followers_count: item.get('followers_count'), - following_count: item.get('following_count'), - statuses_count: item.get('statuses_count'), - }))); - case ACCOUNT_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: + case ACCOUNT_IMPORT: return normalizeAccount(state, action.account); - case FOLLOWERS_FETCH_SUCCESS: - case FOLLOWERS_EXPAND_SUCCESS: - case FOLLOWING_FETCH_SUCCESS: - case FOLLOWING_EXPAND_SUCCESS: - case REBLOGS_FETCH_SUCCESS: - case FAVOURITES_FETCH_SUCCESS: - case COMPOSE_SUGGESTIONS_READY: - case FOLLOW_REQUESTS_FETCH_SUCCESS: - case FOLLOW_REQUESTS_EXPAND_SUCCESS: - case BLOCKS_FETCH_SUCCESS: - case BLOCKS_EXPAND_SUCCESS: - case MUTES_FETCH_SUCCESS: - case MUTES_EXPAND_SUCCESS: - case LIST_ACCOUNTS_FETCH_SUCCESS: - case LIST_EDITOR_SUGGESTIONS_READY: - return action.accounts ? normalizeAccounts(state, action.accounts) : state; - case NOTIFICATIONS_EXPAND_SUCCESS: - case SEARCH_FETCH_SUCCESS: - return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); - case TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - case FAVOURITED_STATUSES_FETCH_SUCCESS: - case FAVOURITED_STATUSES_EXPAND_SUCCESS: - case BOOKMARKED_STATUSES_FETCH_SUCCESS: - case BOOKMARKED_STATUSES_EXPAND_SUCCESS: - return normalizeAccountsFromStatuses(state, action.statuses); - case REBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNREBLOG_SUCCESS: - case UNFAVOURITE_SUCCESS: - case BOOKMARK_SUCCESS: - case UNBOOKMARK_SUCCESS: - return normalizeAccountFromStatus(state, action.response); - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - return normalizeAccountFromStatus(state, action.status); + case ACCOUNTS_IMPORT: + return normalizeAccounts(state, action.accounts); case ACCOUNT_FOLLOW_SUCCESS: - if (action.alreadyFollowing) { - return state; - } - return state.updateIn([action.relationship.id, 'followers_count'], num => num < 0 ? num : num + 1); + return action.alreadyFollowing ? state : + state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); case ACCOUNT_UNFOLLOW_SUCCESS: - return state.updateIn([action.relationship.id, 'followers_count'], num => num < 0 ? num : Math.max(0, num - 1)); + return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 7281cbd61..a79b0dd24 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -31,6 +31,12 @@ import { COMPOSE_UPLOAD_CHANGE_FAIL, COMPOSE_DOODLE_SET, COMPOSE_RESET, + COMPOSE_POLL_ADD, + COMPOSE_POLL_REMOVE, + COMPOSE_POLL_OPTION_ADD, + COMPOSE_POLL_OPTION_CHANGE, + COMPOSE_POLL_OPTION_REMOVE, + COMPOSE_POLL_SETTINGS_CHANGE, } from 'flavours/glitch/actions/compose'; import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines'; import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; @@ -70,6 +76,7 @@ const initialState = ImmutableMap({ is_changing_upload: false, progress: 0, media_attachments: ImmutableList(), + poll: null, suggestion_token: null, suggestions: ImmutableList(), default_advanced_options: ImmutableMap({ @@ -94,6 +101,12 @@ const initialState = ImmutableMap({ }), }); +const initialPoll = ImmutableMap({ + options: ImmutableList(['', '']), + expires_in: 24 * 3600, + multiple: false, +}); + function statusToTextMentions(state, status) { let set = ImmutableOrderedSet([]); @@ -140,6 +153,7 @@ function clearAll(state) { map.set('privacy', state.get('default_privacy')); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); + map.set('poll', null); map.set('idempotencyKey', uuid()); }); }; @@ -336,6 +350,7 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('poll', null); map.update( 'advanced_options', map => map.mergeWith(overwrite, state.get('default_advanced_options')) @@ -424,7 +439,27 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); } + + if (action.status.get('poll')) { + map.set('poll', ImmutableMap({ + options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: 24 * 3600, + })); + } }); + case COMPOSE_POLL_ADD: + return state.set('poll', initialPoll); + case COMPOSE_POLL_REMOVE: + return state.set('poll', null); + case COMPOSE_POLL_OPTION_ADD: + return state.updateIn(['poll', 'options'], options => options.push(action.title)); + case COMPOSE_POLL_OPTION_CHANGE: + return state.setIn(['poll', 'options', action.index], action.title); + case COMPOSE_POLL_OPTION_REMOVE: + return state.updateIn(['poll', 'options'], options => options.delete(action.index)); + case COMPOSE_POLL_SETTINGS_CHANGE: + return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index 5b1ec4abc..7b3e0f651 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -29,6 +29,7 @@ import listEditor from './list_editor'; import listAdder from './list_adder'; import filters from './filters'; import pinnedAccountsEditor from './pinned_accounts_editor'; +import polls from './polls'; const reducers = { dropdown_menu, @@ -61,6 +62,7 @@ const reducers = { listAdder, filters, pinnedAccountsEditor, + polls, }; export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/polls.js b/app/javascript/flavours/glitch/reducers/polls.js new file mode 100644 index 000000000..9956cf83f --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/polls.js @@ -0,0 +1,15 @@ +import { POLLS_IMPORT } from 'mastodon/actions/importer'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); + +const initialState = ImmutableMap(); + +export default function polls(state = initialState, action) { + switch(action.type) { + case POLLS_IMPORT: + return importPolls(state, action.polls); + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index 1beaf73e1..96c9c6d04 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -1,93 +1,25 @@ import { REBLOG_REQUEST, - REBLOG_SUCCESS, REBLOG_FAIL, - UNREBLOG_SUCCESS, FAVOURITE_REQUEST, - FAVOURITE_SUCCESS, FAVOURITE_FAIL, - UNFAVOURITE_SUCCESS, BOOKMARK_REQUEST, - BOOKMARK_SUCCESS, BOOKMARK_FAIL, - UNBOOKMARK_SUCCESS, - PIN_SUCCESS, - UNPIN_SUCCESS, } from 'flavours/glitch/actions/interactions'; import { - COMPOSE_SUBMIT_SUCCESS, -} from 'flavours/glitch/actions/compose'; -import { - STATUS_FETCH_SUCCESS, - CONTEXT_FETCH_SUCCESS, STATUS_MUTE_SUCCESS, STATUS_UNMUTE_SUCCESS, } from 'flavours/glitch/actions/statuses'; import { - TIMELINE_UPDATE, TIMELINE_DELETE, - TIMELINE_EXPAND_SUCCESS, } from 'flavours/glitch/actions/timelines'; -import { - NOTIFICATIONS_UPDATE, - NOTIFICATIONS_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/notifications'; -import { - FAVOURITED_STATUSES_FETCH_SUCCESS, - FAVOURITED_STATUSES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/favourites'; -import { - BOOKMARKED_STATUSES_FETCH_SUCCESS, - BOOKMARKED_STATUSES_EXPAND_SUCCESS, -} from 'flavours/glitch/actions/bookmarks'; -import { - PINNED_STATUSES_FETCH_SUCCESS, -} from 'flavours/glitch/actions/pin_statuses'; -import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search'; -import emojify from 'flavours/glitch/util/emoji'; +import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; -import escapeTextContentForBrowser from 'escape-html'; - -const domParser = new DOMParser(); - -const normalizeStatus = (state, status) => { - if (!status) { - return state; - } - - const normalStatus = { ...status }; - normalStatus.account = status.account.id; - if (status.reblog && status.reblog.id) { - state = normalizeStatus(state, status.reblog); - normalStatus.reblog = status.reblog.id; - } - - // Only calculate these values when status first encountered - // Otherwise keep the ones already in the reducer - if (!state.has(status.id)) { - const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n'); - - const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji; - return obj; - }, {}); - - normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; - normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); - normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); - } +const importStatus = (state, status) => state.set(status.id, fromJS(status)); - return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus))); -}; - -const normalizeStatuses = (state, statuses) => { - statuses.forEach(status => { - state = normalizeStatus(state, status); - }); - - return state; -}; +const importStatuses = (state, statuses) => + state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status))); const deleteStatus = (state, id, references) => { references.forEach(ref => { @@ -101,20 +33,10 @@ const initialState = ImmutableMap(); export default function statuses(state = initialState, action) { switch(action.type) { - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: - case COMPOSE_SUBMIT_SUCCESS: - return normalizeStatus(state, action.status); - case REBLOG_SUCCESS: - case UNREBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNFAVOURITE_SUCCESS: - case BOOKMARK_SUCCESS: - case UNBOOKMARK_SUCCESS: - case PIN_SUCCESS: - case UNPIN_SUCCESS: - return normalizeStatus(state, action.response); + case STATUS_IMPORT: + return importStatus(state, action.status); + case STATUSES_IMPORT: + return importStatuses(state, action.statuses); case FAVOURITE_REQUEST: return state.setIn([action.status.get('id'), 'favourited'], true); case FAVOURITE_FAIL: @@ -131,16 +53,6 @@ export default function statuses(state = initialState, action) { return state.setIn([action.id, 'muted'], true); case STATUS_UNMUTE_SUCCESS: return state.setIn([action.id, 'muted'], false); - case TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - case NOTIFICATIONS_EXPAND_SUCCESS: - case FAVOURITED_STATUSES_FETCH_SUCCESS: - case FAVOURITED_STATUSES_EXPAND_SUCCESS: - case BOOKMARKED_STATUSES_FETCH_SUCCESS: - case BOOKMARKED_STATUSES_EXPAND_SUCCESS: - case PINNED_STATUSES_FETCH_SUCCESS: - case SEARCH_FETCH_SUCCESS: - return normalizeStatuses(state, action.statuses); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); default: diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js index d7d5ac43f..ca71c3833 100644 --- a/app/javascript/flavours/glitch/reducers/timelines.js +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -6,6 +6,7 @@ import { TIMELINE_EXPAND_REQUEST, TIMELINE_EXPAND_FAIL, TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, TIMELINE_DISCONNECT, } from 'flavours/glitch/actions/timelines'; import { @@ -20,6 +21,7 @@ const initialState = ImmutableMap(); const initialTimeline = ImmutableMap({ unread: 0, + online: false, top: true, isLoading: false, hasMore: true, @@ -29,6 +31,8 @@ const initialTimeline = ImmutableMap({ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); + mMap.set('isPartial', isPartial); + if (!next && !isLoadingRecent) mMap.set('hasMore', false); if (!statuses.isEmpty()) { @@ -135,14 +139,13 @@ export default function timelines(state = initialState, action) { return filterTimeline('home', state, action.relationship, action.statuses); case TIMELINE_SCROLL_TOP: return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.update(action.timeline, initialTimeline, map => map.set('online', true)); case TIMELINE_DISCONNECT: return state.update( action.timeline, initialTimeline, - map => map.update( - 'items', - items => items.first() ? items.unshift(null) : items - ) + map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) ); default: return state; diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index fa24cabf2..e14775e44 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -368,6 +368,13 @@ } } +.compose-form__modifiers { + color: $inverted-text-color; + font-family: inherit; + font-size: 14px; + background: $simple-background-color; +} + .composer--options { padding: 10px; background: darken($simple-background-color, 8%); diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 8e90aa545..b9811f25c 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -89,6 +89,10 @@ border-color: lighten($ui-primary-color, 4%); color: lighten($darker-text-color, 4%); } + + &:disabled { + opacity: 0.5; + } } &.button--block { diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss index 3cb592499..323b2e7fe 100644 --- a/app/javascript/flavours/glitch/styles/index.scss +++ b/app/javascript/flavours/glitch/styles/index.scss @@ -16,6 +16,7 @@ @import 'accounts'; @import 'stream_entries'; @import 'components/index'; +@import 'polls'; @import 'about'; @import 'tables'; @import 'admin'; diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss new file mode 100644 index 000000000..4f8c94d83 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/polls.scss @@ -0,0 +1,192 @@ +.poll { + margin-top: 16px; + font-size: 14px; + + li { + margin-bottom: 10px; + position: relative; + height: 18px + 12px; + } + + &__chart { + position: absolute; + top: 0; + left: 0; + height: 100%; + display: inline-block; + border-radius: 4px; + background: darken($ui-primary-color, 14%); + + &.leading { + background: $ui-highlight-color; + } + } + + &__text { + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + input[type=text] { + display: block; + box-sizing: border-box; + flex: 1 1 auto; + width: 20px; + font-size: 14px; + color: $inverted-text-color; + display: block; + outline: 0; + font-family: inherit; + background: $simple-background-color; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + + &:focus { + border-color: $highlight-text-color; + } + } + + &.selectable { + cursor: pointer; + } + + &.editable { + display: flex; + align-items: center; + } + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checkbox { + border-radius: 4px; + } + + &.active { + border-color: $valid-value-color; + background: $valid-value-color; + } + } + + &__number { + display: inline-block; + width: 36px; + font-weight: 700; + padding: 0 10px; + text-align: right; + } + + &__footer { + padding-top: 6px; + padding-bottom: 5px; + color: $dark-text-color; + } + + &__link { + display: inline; + background: transparent; + padding: 0; + margin: 0; + border: 0; + color: $dark-text-color; + text-decoration: underline; + font-size: inherit; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + .button { + height: 36px; + padding: 0 16px; + margin-right: 10px; + font-size: 14px; + } +} + +.compose-form__poll-wrapper { + border-top: 1px solid darken($simple-background-color, 8%); + + ul { + padding: 10px; + } + + .poll__footer { + border-top: 1px solid darken($simple-background-color, 8%); + padding: 10px; + display: flex; + align-items: center; + + button, + select { + flex: 1 1 50%; + } + } + + .button.button-secondary { + font-size: 14px; + font-weight: 400; + padding: 6px 10px; + height: auto; + line-height: inherit; + color: $action-button-color; + border-color: $action-button-color; + margin-right: 5px; + } + + li { + display: flex; + align-items: center; + + .poll__text { + flex: 0 0 auto; + width: calc(100% - (23px + 6px)); + margin-right: 6px; + } + } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } + + .icon-button.disabled { + color: darken($simple-background-color, 14%); + } +} diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index a3c65563c..62588eeaa 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -22,6 +22,7 @@ export const deleteModal = getMeta('delete_modal'); export const me = getMeta('me'); export const searchEnabled = getMeta('search_enabled'); export const maxChars = (initialState && initialState.max_toot_chars) || 500; +export const pollLimits = (initialState && initialState.poll_limits); export const invitesEnabled = getMeta('invites_enabled'); export const version = getMeta('version'); export const mascot = getMeta('mascot'); diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js index 9928d0dd7..306a068b7 100644 --- a/app/javascript/flavours/glitch/util/stream.js +++ b/app/javascript/flavours/glitch/util/stream.js @@ -2,11 +2,11 @@ import WebSocketClient from 'websocket.js'; const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); -export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) { +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { return (dispatch, getState) => { const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); const accessToken = getState().getIn(['meta', 'access_token']); - const { onDisconnect, onReceive } = callbacks(dispatch, getState); + const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); let polling = null; @@ -28,6 +28,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ if (pollingRefresh) { clearPolling(); } + + onConnect(); }, disconnected () { @@ -47,6 +49,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ clearPolling(); pollingRefresh(dispatch); } + + onConnect(); }, }); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 0be2a5cd4..d65d41048 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -51,8 +51,16 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; +export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; +export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; +export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; +export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; +export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; +export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; + const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, }); export function changeCompose(text) { @@ -131,6 +139,7 @@ export function submitCompose(routerHistory) { sensitive: getState().getIn(['compose', 'sensitive']), spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), visibility: getState().getIn(['compose', 'privacy']), + poll: getState().getIn(['compose', 'poll'], null), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -149,7 +158,9 @@ export function submitCompose(routerHistory) { // into the columns const insertIfOnline = timelineId => { - if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { + const timeline = getState().getIn(['timelines', timelineId]); + + if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) { dispatch(updateTimeline(timelineId, { ...response.data })); } }; @@ -199,6 +210,12 @@ export function uploadCompose(files) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } + + if (getState().getIn(['compose', 'poll'])) { + dispatch(showAlert(undefined, messages.uploadErrorPoll)); + return; + } + dispatch(uploadComposeRequest()); for (const [i, f] of Array.from(files).entries()) { @@ -484,4 +501,46 @@ export function changeComposing(value) { type: COMPOSE_COMPOSING_CHANGE, value, }; -} +}; + +export function addPoll() { + return { + type: COMPOSE_POLL_ADD, + }; +}; + +export function removePoll() { + return { + type: COMPOSE_POLL_REMOVE, + }; +}; + +export function addPollOption(title) { + return { + type: COMPOSE_POLL_OPTION_ADD, + title, + }; +}; + +export function changePollOption(index, title) { + return { + type: COMPOSE_POLL_OPTION_CHANGE, + index, + title, + }; +}; + +export function removePollOption(index) { + return { + type: COMPOSE_POLL_OPTION_REMOVE, + index, + }; +}; + +export function changePollSettings(expiresIn, isMultiple) { + return { + type: COMPOSE_POLL_SETTINGS_CHANGE, + expiresIn, + isMultiple, + }; +}; diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js index 3c2ea9680..c6e062ef7 100644 --- a/app/javascript/mastodon/actions/conversations.js +++ b/app/javascript/mastodon/actions/conversations.js @@ -41,13 +41,15 @@ export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']); } + const isLoadingRecent = !!params.since_id; + api(getState).get('/api/v1/conversations', { params }) .then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); - dispatch(expandConversationsSuccess(response.data, next ? next.uri : null)); + dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); }) .catch(err => dispatch(expandConversationsFail(err))); }; @@ -56,10 +58,11 @@ export const expandConversationsRequest = () => ({ type: CONVERSATIONS_FETCH_REQUEST, }); -export const expandConversationsSuccess = (conversations, next) => ({ +export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({ type: CONVERSATIONS_FETCH_SUCCESS, conversations, next, + isLoadingRecent, }); export const expandConversationsFail = error => ({ diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 931711f4b..f4372fb31 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,11 +1,10 @@ -// import { autoPlayGif } from '../../initial_state'; -// import { putAccounts, putStatuses } from '../../storage/modifier'; -import { normalizeAccount, normalizeStatus } from './normalizer'; +import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer'; -export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; +export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; -export const STATUS_IMPORT = 'STATUS_IMPORT'; +export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; +export const POLLS_IMPORT = 'POLLS_IMPORT'; function pushUnique(array, object) { if (array.every(element => element.id !== object.id)) { @@ -29,6 +28,10 @@ export function importStatuses(statuses) { return { type: STATUSES_IMPORT, statuses }; } +export function importPolls(polls) { + return { type: POLLS_IMPORT, polls }; +} + export function importFetchedAccount(account) { return importFetchedAccounts([account]); } @@ -45,7 +48,6 @@ export function importFetchedAccounts(accounts) { } accounts.forEach(processAccount); - //putAccounts(normalAccounts, !autoPlayGif); return importAccounts(normalAccounts); } @@ -58,6 +60,7 @@ export function importFetchedStatuses(statuses) { return (dispatch, getState) => { const accounts = []; const normalStatuses = []; + const polls = []; function processStatus(status) { pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); @@ -66,12 +69,22 @@ export function importFetchedStatuses(statuses) { if (status.reblog && status.reblog.id) { processStatus(status.reblog); } + + if (status.poll && status.poll.id) { + pushUnique(polls, normalizePoll(status.poll)); + } } statuses.forEach(processStatus); - //putStatuses(normalStatuses); + dispatch(importPolls(polls)); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); }; } + +export function importFetchedPoll(poll) { + return dispatch => { + dispatch(importPolls([normalizePoll(poll)])); + }; +} diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 34a4150fa..ea80c0efb 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -43,6 +43,10 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.reblog = status.reblog.id; } + if (status.poll && status.poll.id) { + normalStatus.poll = status.poll.id; + } + // Only calculate these values when status first encountered // Otherwise keep the ones already in the reducer if (normalOldStatus) { @@ -63,3 +67,14 @@ export function normalizeStatus(status, normalOldStatus) { return normalStatus; } + +export function normalizePoll(poll) { + const normalPoll = { ...poll }; + + normalPoll.options = poll.options.map(option => ({ + ...option, + title_emojified: emojify(escapeTextContentForBrowser(option.title)), + })); + + return normalPoll; +} diff --git a/app/javascript/mastodon/actions/polls.js b/app/javascript/mastodon/actions/polls.js new file mode 100644 index 000000000..8e8b82df5 --- /dev/null +++ b/app/javascript/mastodon/actions/polls.js @@ -0,0 +1,60 @@ +import api from '../api'; +import { importFetchedPoll } from './importer'; + +export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; +export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; +export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; + +export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; +export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; +export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; + +export const vote = (pollId, choices) => (dispatch, getState) => { + dispatch(voteRequest()); + + api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(voteSuccess(data)); + }) + .catch(err => dispatch(voteFail(err))); +}; + +export const fetchPoll = pollId => (dispatch, getState) => { + dispatch(fetchPollRequest()); + + api(getState).get(`/api/v1/polls/${pollId}`) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(fetchPollSuccess(data)); + }) + .catch(err => dispatch(fetchPollFail(err))); +}; + +export const voteRequest = () => ({ + type: POLL_VOTE_REQUEST, +}); + +export const voteSuccess = poll => ({ + type: POLL_VOTE_SUCCESS, + poll, +}); + +export const voteFail = error => ({ + type: POLL_VOTE_FAIL, + error, +}); + +export const fetchPollRequest = () => ({ + type: POLL_FETCH_REQUEST, +}); + +export const fetchPollSuccess = poll => ({ + type: POLL_FETCH_SUCCESS, + poll, +}); + +export const fetchPollFail = error => ({ + type: POLL_FETCH_FAIL, + error, +}); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index e7c89b4ba..1794538e2 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -140,7 +140,11 @@ export function redraft(status) { export function deleteStatus(id, router, withRedraft = false) { return (dispatch, getState) => { - const status = getState().getIn(['statuses', id]); + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } dispatch(deleteStatusRequest(id)); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index cd319709d..c678e9393 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -3,6 +3,7 @@ import { updateTimeline, deleteFromTimelines, expandHomeTimeline, + connectTimeline, disconnectTimeline, } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; @@ -16,7 +17,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, return connectStream (path, pollingRefresh, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + }, + onDisconnect() { dispatch(disconnectTimeline(timelineId)); }, diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 6e7bd027c..d92385e95 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -12,6 +12,7 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export function updateTimeline(timeline, status, accept) { @@ -143,6 +144,13 @@ export function scrollTopTimeline(timeline, top) { }; }; +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline, + }; +}; + export function disconnectTimeline(timeline) { return { type: TIMELINE_DISCONNECT, diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index fbb42f78f..9d8a8d06b 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -86,6 +86,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} style={style} tabIndex={tabIndex} + disabled={disabled} > <Icon id={icon} fixedWidth aria-hidden='true' /> </button> @@ -104,6 +105,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} style={style} tabIndex={tabIndex} + disabled={disabled} > <Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' /> </button> diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js new file mode 100644 index 000000000..a1b297ce7 --- /dev/null +++ b/app/javascript/mastodon/components/poll.js @@ -0,0 +1,158 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { vote, fetchPoll } from 'mastodon/actions/polls'; +import Motion from 'mastodon/features/ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'mastodon/features/emoji/emoji'; + +const messages = defineMessages({ + moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, + seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, + minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, + hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, + days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, +}); + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const timeRemainingString = (intl, date, now) => { + const delta = date.getTime() - now; + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.moments); + } else if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); + } + + return relativeTime; +}; + +export default @injectIntl +class Poll extends ImmutablePureComponent { + + static propTypes = { + poll: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func, + disabled: PropTypes.bool, + }; + + state = { + selected: {}, + }; + + handleOptionChange = e => { + const { target: { value } } = e; + + if (this.props.poll.get('multiple')) { + const tmp = { ...this.state.selected }; + if (tmp[value]) { + delete tmp[value]; + } else { + tmp[value] = true; + } + this.setState({ selected: tmp }); + } else { + const tmp = {}; + tmp[value] = true; + this.setState({ selected: tmp }); + } + }; + + handleVote = () => { + if (this.props.disabled) { + return; + } + + this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected))); + }; + + handleRefresh = () => { + if (this.props.disabled) { + return; + } + + this.props.dispatch(fetchPoll(this.props.poll.get('id'))); + }; + + renderOption (option, optionIndex) { + const { poll, disabled } = this.props; + const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100; + const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); + const active = !!this.state.selected[`${optionIndex}`]; + const showResults = poll.get('voted') || poll.get('expired'); + + return ( + <li key={option.get('title')}> + {showResults && ( + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> + {({ width }) => + <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> + } + </Motion> + )} + + <label className={classNames('poll__text', { selectable: !showResults })}> + <input + name='vote-options' + type={poll.get('multiple') ? 'checkbox' : 'radio'} + value={optionIndex} + checked={active} + onChange={this.handleOptionChange} + disabled={disabled} + /> + + {!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />} + {showResults && <span className='poll__number'>{Math.round(percent)}%</span>} + + <span dangerouslySetInnerHTML={{ __html: option.get('title_emojified', emojify(escapeTextContentForBrowser(option.get('title')))) }} /> + </label> + </li> + ); + } + + render () { + const { poll, intl } = this.props; + + if (!poll) { + return null; + } + + const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now()); + const showResults = poll.get('voted') || poll.get('expired'); + const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); + + return ( + <div className='poll'> + <ul> + {poll.get('options').map((option, i) => this.renderOption(option, i))} + </ul> + + <div className='poll__footer'> + {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} + {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} + <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> + {poll.get('expires_at') && <span> · {timeRemaining}</span>} + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 6270d3c92..e10faedf8 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -16,6 +16,7 @@ import { MediaGallery, Video } from '../features/ui/util/async-components'; import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; +import PollContainer from 'mastodon/containers/poll_container'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -270,7 +271,9 @@ class Status extends ImmutablePureComponent { status = status.get('reblog'); } - if (status.get('media_attachments').size > 0) { + if (status.get('poll')) { + media = <PollContainer pollId={status.get('poll')} />; + } else if (status.get('media_attachments').size > 0) { if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = ( <AttachmentList diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 43bb39403..51d4f0fed 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -6,6 +6,7 @@ import { getLocale } from '../locales'; import MediaGallery from '../components/media_gallery'; import Video from '../features/video'; import Card from '../features/status/components/card'; +import Poll from 'mastodon/components/poll'; import ModalRoot from '../components/modal_root'; import MediaModal from '../features/ui/components/media_modal'; import { List as ImmutableList, fromJS } from 'immutable'; @@ -13,7 +14,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; export default class MediaContainer extends PureComponent { @@ -54,11 +55,12 @@ export default class MediaContainer extends PureComponent { {[].map.call(components, (component, i) => { const componentName = component.getAttribute('data-component'); const Component = MEDIA_COMPONENTS[componentName]; - const { media, card, ...props } = JSON.parse(component.getAttribute('data-props')); + const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props')); Object.assign(props, { ...(media ? { media: fromJS(media) } : {}), ...(card ? { card: fromJS(card) } : {}), + ...(poll ? { poll: fromJS(poll) } : {}), ...(componentName === 'Video' ? { onOpenVideo: this.handleOpenVideo, diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js new file mode 100644 index 000000000..cd7216de7 --- /dev/null +++ b/app/javascript/mastodon/containers/poll_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Poll from 'mastodon/components/poll'; + +const mapStateToProps = (state, { pollId }) => ({ + poll: state.getIn(['polls', pollId]), +}); + +export default connect(mapStateToProps)(Poll); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 8909b39fd..d47b788ce 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -5,12 +5,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; +import PollButtonContainer from '../containers/poll_button_container'; import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import SensitiveButtonContainer from '../containers/sensitive_button_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; +import PollFormContainer from '../containers/poll_form_container'; import UploadFormContainer from '../containers/upload_form_container'; import WarningContainer from '../containers/warning_container'; import { isMobile } from '../../../is_mobile'; @@ -206,11 +208,13 @@ class ComposeForm extends ImmutablePureComponent { <div className='compose-form__modifiers'> <UploadFormContainer /> + <PollFormContainer /> </div> <div className='compose-form__buttons-wrapper'> <div className='compose-form__buttons'> <UploadButtonContainer /> + <PollButtonContainer /> <PrivacyDropdownContainer /> <SensitiveButtonContainer /> <SpoilerButtonContainer /> diff --git a/app/javascript/mastodon/features/compose/components/poll_button.js b/app/javascript/mastodon/features/compose/components/poll_button.js new file mode 100644 index 000000000..76f96bfa4 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/poll_button.js @@ -0,0 +1,55 @@ +import React from 'react'; +import IconButton from '../../../components/icon_button'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' }, + remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' }, +}); + +const iconStyle = { + height: null, + lineHeight: '27px', +}; + +export default +@injectIntl +class PollButton extends React.PureComponent { + + static propTypes = { + disabled: PropTypes.bool, + unavailable: PropTypes.bool, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onClick(); + } + + render () { + const { intl, active, unavailable, disabled } = this.props; + + if (unavailable) { + return null; + } + + return ( + <div className='compose-form__poll-button'> + <IconButton + icon='tasks' + title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)} + disabled={disabled} + onClick={this.handleClick} + className={`compose-form__poll-button-icon ${active ? 'active' : ''}`} + size={18} + inverted + style={iconStyle} + /> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js new file mode 100644 index 000000000..ff0062425 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -0,0 +1,121 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from 'mastodon/components/icon_button'; +import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; + +const messages = defineMessages({ + option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, + add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, + remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, + poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, +}); + +@injectIntl +class Option extends React.PureComponent { + + static propTypes = { + title: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, + isPollMultiple: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleOptionTitleChange = e => { + this.props.onChange(this.props.index, e.target.value); + }; + + handleOptionRemove = () => { + this.props.onRemove(this.props.index); + }; + + render () { + const { isPollMultiple, title, index, intl } = this.props; + + return ( + <li> + <label className='poll__text editable'> + <span className={classNames('poll__input', { checkbox: isPollMultiple })} /> + + <input + type='text' + placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} + maxlength={25} + value={title} + onChange={this.handleOptionTitleChange} + /> + </label> + + <div className='poll__cancel'> + <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} /> + </div> + </li> + ); + } + +} + +export default +@injectIntl +class PollForm extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.list, + expiresIn: PropTypes.number, + isMultiple: PropTypes.bool, + onChangeOption: PropTypes.func.isRequired, + onAddOption: PropTypes.func.isRequired, + onRemoveOption: PropTypes.func.isRequired, + onChangeSettings: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleAddOption = () => { + this.props.onAddOption(''); + }; + + handleSelectDuration = e => { + this.props.onChangeSettings(e.target.value, this.props.isMultiple); + }; + + render () { + const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props; + + if (!options) { + return null; + } + + return ( + <div className='compose-form__poll-wrapper'> + <ul> + {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} />)} + </ul> + + <div className='poll__footer'> + {options.size < 4 && ( + <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> + )} + + <select value={expiresIn} onChange={this.handleSelectDuration}> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js index db55ad70b..90e2769f3 100644 --- a/app/javascript/mastodon/features/compose/components/upload_button.js +++ b/app/javascript/mastodon/features/compose/components/upload_button.js @@ -29,6 +29,7 @@ class UploadButton extends ImmutablePureComponent { static propTypes = { disabled: PropTypes.bool, + unavailable: PropTypes.bool, onSelectFile: PropTypes.func.isRequired, style: PropTypes.object, resetFileKey: PropTypes.number, @@ -51,8 +52,11 @@ class UploadButton extends ImmutablePureComponent { } render () { + const { intl, resetFileKey, unavailable, disabled, acceptContentTypes } = this.props; - const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; + if (unavailable) { + return null; + } return ( <div className='compose-form__upload-button'> diff --git a/app/javascript/mastodon/features/compose/containers/poll_button_container.js b/app/javascript/mastodon/features/compose/containers/poll_button_container.js new file mode 100644 index 000000000..8f1cb7c10 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/poll_button_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import PollButton from '../components/poll_button'; +import { addPoll, removePoll } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + unavailable: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0), + active: state.getIn(['compose', 'poll']) !== null, +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch((_, getState) => { + if (getState().getIn(['compose', 'poll'])) { + dispatch(removePoll()); + } else { + dispatch(addPoll()); + } + }); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PollButton); diff --git a/app/javascript/mastodon/features/compose/containers/poll_form_container.js b/app/javascript/mastodon/features/compose/containers/poll_form_container.js new file mode 100644 index 000000000..da795a291 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/poll_form_container.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import PollForm from '../components/poll_form'; +import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + options: state.getIn(['compose', 'poll', 'options']), + expiresIn: state.getIn(['compose', 'poll', 'expires_in']), + isMultiple: state.getIn(['compose', 'poll', 'multiple']), +}); + +const mapDispatchToProps = dispatch => ({ + onAddOption(title) { + dispatch(addPollOption(title)); + }, + + onRemoveOption(index) { + dispatch(removePollOption(index)); + }, + + onChangeOption(index, title) { + dispatch(changePollOption(index, title)); + }, + + onChangeSettings(expiresIn, isMultiple) { + dispatch(changePollSettings(expiresIn, isMultiple)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PollForm); diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js index 1f1d915bc..d8b8c4b6e 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js @@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose'; const mapStateToProps = state => ({ disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + unavailable: state.getIn(['compose', 'poll']) !== null, resetFileKey: state.getIn(['compose', 'resetFileKey']), }); diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 3ffa7a681..097f91c16 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -16,7 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, - isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null, + isPartial: state.getIn(['timelines', 'home', 'isPartial']), }); export default @connect(mapStateToProps) diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 49bc43a7b..5cd50f055 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -14,6 +14,7 @@ import Video from '../../video'; import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; +import PollContainer from 'mastodon/containers/poll_container'; export default class DetailedStatus extends ImmutablePureComponent { @@ -105,7 +106,9 @@ export default class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } - if (status.get('media_attachments').size > 0) { + if (status.get('poll')) { + media = <PollContainer pollId={status.get('poll')} />; + } else if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = <AttachmentList media={status.get('media_attachments')} />; } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index 7de65f91f..5cd494314 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -128,7 +128,7 @@ "empty_column.lists": "ليس عندك أية قائمة بعد. سوف تظهر قائمتك هنا إن قمت بإنشاء واحدة.", "empty_column.mutes": "لم تقم بكتم أي مستخدم بعد.", "empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.", - "empty_column.public": "لا يوجد أي شيء هنا ! قم بنشر شيء ما للعامة، أو إتبع مستخدمين آخرين في الخوادم المثيلة الأخرى لملء خيط المحادثات العام", + "empty_column.public": "لا يوجد أي شيء هنا ! قم بنشر شيء ما للعامة، أو إتبع المستخدمين الآخرين المتواجدين على الخوادم الأخرى لملء خيط المحادثات", "follow_request.authorize": "ترخيص", "follow_request.reject": "رفض", "getting_started.developers": "المُطوِّرون", @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "و {additional}", "hashtag.column_header.tag_mode.any": "أو {additional}", "hashtag.column_header.tag_mode.none": "بدون {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "لم يُعثَر على أي اقتراح", + "hashtag.column_settings.select.placeholder": "قم بإدخال وسوم…", "hashtag.column_settings.tag_mode.all": "كلها", "hashtag.column_settings.tag_mode.any": "أي كان مِن هذه", "hashtag.column_settings.tag_mode.none": "لا شيء مِن هذه", @@ -206,7 +206,7 @@ "lists.account.remove": "إحذف من القائمة", "lists.delete": "Delete list", "lists.edit": "تعديل القائمة", - "lists.edit.submit": "Change title", + "lists.edit.submit": "تعديل العنوان", "lists.new.create": "إنشاء قائمة", "lists.new.title_placeholder": "عنوان القائمة الجديدة", "lists.search": "إبحث في قائمة الحسابات التي تُتابِعها", @@ -227,7 +227,7 @@ "navigation_bar.favourites": "المفضلة", "navigation_bar.filters": "الكلمات المكتومة", "navigation_bar.follow_requests": "طلبات المتابعة", - "navigation_bar.info": "معلومات إضافية", + "navigation_bar.info": "عن هذا الخادم", "navigation_bar.keyboard_shortcuts": "إختصارات لوحة المفاتيح", "navigation_bar.lists": "القوائم", "navigation_bar.logout": "خروج", @@ -260,6 +260,10 @@ "notifications.filter.follows": "يتابِع", "notifications.filter.mentions": "الإشارات", "notifications.group": "{count} إشعارات", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "إضبط خصوصية المنشور", "privacy.direct.long": "أنشر إلى المستخدمين المشار إليهم فقط", "privacy.direct.short": "مباشر", @@ -279,7 +283,7 @@ "reply_indicator.cancel": "إلغاء", "report.forward": "التحويل إلى {target}", "report.forward_hint": "هذا الحساب ينتمي إلى خادوم آخَر. هل تودّ إرسال نسخة مجهولة مِن التقرير إلى هنالك أيضًا ؟", - "report.hint": "سوف يتم إرسال التقرير إلى مُشرِفي مثيل خادومكم. بإمكانك الإدلاء بشرح عن سبب الإبلاغ عن الحساب أسفله :", + "report.hint": "سوف يتم إرسال التقرير إلى المُشرِفين على خادومكم. بإمكانكم الإدلاء بشرح عن سبب الإبلاغ عن الحساب أسفله:", "report.placeholder": "تعليقات إضافية", "report.submit": "إرسال", "report.target": "إبلاغ", @@ -300,7 +304,7 @@ "status.block": "Block @{name}", "status.cancel_reblog_private": "إلغاء الترقية", "status.cannot_reblog": "تعذرت ترقية هذا المنشور", - "status.copy": "Copy link to status", + "status.copy": "نسخ رابط المنشور", "status.delete": "إحذف", "status.detailed_status": "تفاصيل المحادثة", "status.direct": "رسالة خاصة إلى @{name}", @@ -342,11 +346,16 @@ "tabs_bar.local_timeline": "المحلي", "tabs_bar.notifications": "الإخطارات", "tabs_bar.search": "البحث", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} آخرون {people}} يتحدثون", "ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.", "upload_area.title": "إسحب ثم أفلت للرفع", "upload_button.label": "إضافة وسائط (JPEG، PNG، GIF، WebM، MP4، MOV)", - "upload_error.limit": "File upload limit exceeded.", + "upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.", "upload_form.description": "وصف للمعاقين بصريا", "upload_form.focus": "قص", "upload_form.undo": "حذف", diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json index 1e7ecb550..3da1030fb 100644 --- a/app/javascript/mastodon/locales/ast.json +++ b/app/javascript/mastodon/locales/ast.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} avisos", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Llocal", "tabs_bar.notifications": "Avisos", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "El borrador va perdese si coles de Mastodon.", "upload_area.title": "Drag & drop to upload", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index 9e5d46503..080200ebc 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Известия", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 8d1d4777b..bc572d7a2 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Seguiments", "notifications.filter.mentions": "Mencions", "notifications.group": "{count} notificacions", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Ajusta l'estat de privacitat", "privacy.direct.long": "Publicar només per als usuaris esmentats", "privacy.direct.short": "Directe", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificacions", "tabs_bar.search": "Cerca", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, una {person} altres {people}} parlant", "ui.beforeunload": "El vostre esborrany es perdrà si sortiu de Mastodon.", "upload_area.title": "Arrossega i deixa anar per carregar", diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json index c308d807a..6d5d11e48 100644 --- a/app/javascript/mastodon/locales/co.json +++ b/app/javascript/mastodon/locales/co.json @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "è {additional}", "hashtag.column_header.tag_mode.any": "o {additional}", "hashtag.column_header.tag_mode.none": "senza {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "Nisuna sugestione trova", + "hashtag.column_settings.select.placeholder": "Entrà l'hashtag…", "hashtag.column_settings.tag_mode.all": "Tutti quessi", "hashtag.column_settings.tag_mode.any": "Unu di quessi", "hashtag.column_settings.tag_mode.none": "Nisunu di quessi", @@ -206,7 +206,7 @@ "lists.account.remove": "Toglie di a lista", "lists.delete": "Supprime a lista", "lists.edit": "Mudificà a lista", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Cambià u titulu", "lists.new.create": "Aghjustà una lista", "lists.new.title_placeholder": "Titulu di a lista", "lists.search": "Circà indè i vostr'abbunamenti", @@ -260,6 +260,10 @@ "notifications.filter.follows": "Abbunamenti", "notifications.filter.mentions": "Minzione", "notifications.group": "{count} nutificazione", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Mudificà a cunfidenzialità di u statutu", "privacy.direct.long": "Mandà solu à quelli chì so mintuvati", "privacy.direct.short": "Direttu", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lucale", "tabs_bar.notifications": "Nutificazione", "tabs_bar.search": "Cercà", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu", "ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.", "upload_area.title": "Drag & drop per caricà un fugliale", diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index d26ef8284..a9442d803 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -72,7 +72,7 @@ "compose_form.hashtag_warning": "Tento toot nebude zobrazen pod žádným hashtagem, neboť je neuvedený. Pouze veřejné tooty mohou být vyhledány podle hashtagu.", "compose_form.lock_disclaimer": "Váš účet není {locked}. Kdokoliv vás může sledovat a vidět vaše příspěvky pouze pro sledující.", "compose_form.lock_disclaimer.lock": "uzamčen", - "compose_form.placeholder": "Co máte na mysli?", + "compose_form.placeholder": "Co se vám honí hlavou?", "compose_form.publish": "Tootnout", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive.marked": "Mediální obsah je označen jako citlivý", @@ -84,7 +84,7 @@ "confirmations.block.confirm": "Blokovat", "confirmations.block.message": "Jste si jistý/á, že chcete zablokovat uživatele {name}?", "confirmations.delete.confirm": "Smazat", - "confirmations.delete.message": "Jste si jistý/á, že chcete smazat tento příspěvek?", + "confirmations.delete.message": "Jste si jistý/á, že chcete smazat tento toot?", "confirmations.delete_list.confirm": "Smazat", "confirmations.delete_list.message": "Jste si jistý/á, že chcete tento seznam navždy vymazat?", "confirmations.domain_block.confirm": "Skrýt celou doménu", @@ -92,12 +92,12 @@ "confirmations.mute.confirm": "Ignorovat", "confirmations.mute.message": "Jste si jistý/á, že chcete ignorovat uživatele {name}?", "confirmations.redraft.confirm": "Vymazat a přepsat", - "confirmations.redraft.message": "Jste si jistý/á, že chcete vymazat a přepsat tento příspěvek? Oblíbení a boosty budou ztraceny a odpovědi na původní příspěvek budou opuštěny.", + "confirmations.redraft.message": "Jste si jistý/á, že chcete vymazat a přepsat tento toot? Oblíbení a boosty budou ztraceny a odpovědi na původní příspěvek budou opuštěny.", "confirmations.reply.confirm": "Odpovědět", "confirmations.reply.message": "Odpovězením nyní přepíšete zprávu, kterou aktuálně píšete. Jste si jistý/á, že chcete pokračovat?", "confirmations.unfollow.confirm": "Přestat sledovat", "confirmations.unfollow.message": "jste si jistý/á, že chcete přestat sledovat uživatele {name}?", - "embed.instructions": "Pro přidání příspěvku na vaši webovou stránku zkopírujte níže uvedený kód.", + "embed.instructions": "Pro přidání tootu na vaši webovou stránku zkopírujte níže uvedený kód.", "embed.preview": "Takhle to bude vypadat:", "emoji_button.activity": "Aktivita", "emoji_button.custom": "Vlastní", @@ -108,7 +108,7 @@ "emoji_button.not_found": "Žádná emoji!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "Předměty", "emoji_button.people": "Lidé", - "emoji_button.recent": "Často používané", + "emoji_button.recent": "Často používaná", "emoji_button.search": "Hledat...", "emoji_button.search_results": "Výsledky hledání", "emoji_button.symbols": "Symboly", @@ -124,7 +124,7 @@ "empty_column.hashtag": "Pod tímto hashtagem ještě nic není.", "empty_column.home": "Vaše domovská časová osa je prázdná! Začněte navštívením {public} nebo použijte hledání a seznamte se s dalšími uživateli.", "empty_column.home.public_timeline": "veřejné časové osy", - "empty_column.list": "V tomto seznamu ještě nic není. Pokud budou členové tohoto seznamu psát nové příspěvky, objeví se zde.", + "empty_column.list": "V tomto seznamu ještě nic není. Pokud budou členové tohoto seznamu psát nové tooty, objeví se zde.", "empty_column.lists": "Ještě nemáte žádný seznam. Pokud nějaký vytvoříte, zobrazí se zde.", "empty_column.mutes": "Ještě neignorujete žádné uživatele.", "empty_column.notifications": "Ještě nemáte žádná oznámení. Začněte konverzaci komunikováním s ostatními.", @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "a {additional}", "hashtag.column_header.tag_mode.any": "nebo {additional}", "hashtag.column_header.tag_mode.none": "bez {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "Žádné návrhy nenalezeny", + "hashtag.column_settings.select.placeholder": "Zadejte hashtagy…", "hashtag.column_settings.tag_mode.all": "Všechny z těchto", "hashtag.column_settings.tag_mode.any": "Jakékoliv z těchto", "hashtag.column_settings.tag_mode.none": "Žádné z těchto", @@ -171,12 +171,12 @@ "keyboard_shortcuts.back": "k návratu zpět", "keyboard_shortcuts.blocked": "k otevření seznamu blokovaných uživatelů", "keyboard_shortcuts.boost": "k boostnutí", - "keyboard_shortcuts.column": "k zaměření na příspěvek v jednom ze sloupců", + "keyboard_shortcuts.column": "k zaměření na toot v jednom ze sloupců", "keyboard_shortcuts.compose": "k zaměření na psací prostor", "keyboard_shortcuts.description": "Popis", "keyboard_shortcuts.direct": "k otevření sloupce s přímými zprávami", "keyboard_shortcuts.down": "k posunutí dolů v seznamu", - "keyboard_shortcuts.enter": "k otevření příspěvku", + "keyboard_shortcuts.enter": "k otevření tootu", "keyboard_shortcuts.favourite": "k oblíbení", "keyboard_shortcuts.favourites": "k otevření seznamu oblíbených", "keyboard_shortcuts.federated": "k otevření federované časové osy", @@ -206,7 +206,7 @@ "lists.account.remove": "Odebrat ze seznamu", "lists.delete": "Smazat seznam", "lists.edit": "Upravit seznam", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Změnit název", "lists.new.create": "Přidat seznam", "lists.new.title_placeholder": "Název nového seznamu", "lists.search": "Hledejte mezi lidmi, které sledujete", @@ -237,10 +237,10 @@ "navigation_bar.preferences": "Předvolby", "navigation_bar.public_timeline": "Federovaná časová osa", "navigation_bar.security": "Zabezpečení", - "notification.favourite": "{name} si oblíbil/a váš příspěvek", + "notification.favourite": "{name} si oblíbil/a váš toot", "notification.follow": "{name} vás začal/a sledovat", "notification.mention": "{name} vás zmínil/a", - "notification.reblog": "{name} boostnul/a váš příspěvek", + "notification.reblog": "{name} boostnul/a váš toot", "notifications.clear": "Vymazat oznámení", "notifications.clear_confirmation": "Jste si jistý/á, že chcete trvale vymazat všechna vaše oznámení?", "notifications.column_settings.alert": "Desktopová oznámení", @@ -260,7 +260,11 @@ "notifications.filter.follows": "Sledování", "notifications.filter.mentions": "Zmínky", "notifications.group": "{count} oznámení", - "privacy.change": "Změnit soukromí příspěvku", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", + "privacy.change": "Změnit soukromí tootu", "privacy.direct.long": "Odeslat pouze zmíněným uživatelům", "privacy.direct.short": "Přímý", "privacy.private.long": "Odeslat pouze sledujícím", @@ -285,9 +289,9 @@ "report.target": "Nahlášení uživatele {target}", "search.placeholder": "Hledat", "search_popout.search_format": "Pokročilé hledání", - "search_popout.tips.full_text": "Jednoduchý textový výpis příspěvků, které jste napsal/a, oblíbil/a si, boostnul/a, nebo v nich byl/a zmíněn/a, včetně odpovídajících přezdívek, zobrazovaných jmen a hashtagů.", + "search_popout.tips.full_text": "Jednoduchý textový výpis tootů, které jste napsal/a, oblíbil/a si, boostnul/a, nebo v nich byl/a zmíněn/a, včetně odpovídajících přezdívek, zobrazovaných jmen a hashtagů.", "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "příspěvek", + "search_popout.tips.status": "toot", "search_popout.tips.text": "Jednoduchý textový výpis odpovídajících zobrazovaných jmen, přezdívek a hashtagů", "search_popout.tips.user": "uživatel", "search_results.accounts": "Lidé", @@ -296,11 +300,11 @@ "search_results.total": "{count, number} {count, plural, one {výsledek} few {výsledky} many {výsledku} other {výsledků}}", "standalone.public_title": "Nahlédněte dovnitř...", "status.admin_account": "Otevřít moderační rozhraní pro uživatele @{name}", - "status.admin_status": "Otevřít tento příspěvek v moderačním rozhraní", + "status.admin_status": "Otevřít tento toot v moderačním rozhraní", "status.block": "Zablokovat uživatele @{name}", "status.cancel_reblog_private": "Zrušit boost", "status.cannot_reblog": "Tento příspěvek nemůže být boostnutý", - "status.copy": "Kopírovat odkaz k příspěvku", + "status.copy": "Kopírovat odkaz k tootu", "status.delete": "Smazat", "status.detailed_status": "Detailní zobrazení konverzace", "status.direct": "Poslat přímou zprávu uživateli @{name}", @@ -313,7 +317,7 @@ "status.more": "Více", "status.mute": "Ignorovat uživatele @{name}", "status.mute_conversation": "Ignorovat konverzaci", - "status.open": "Rozbalit tento příspěvek", + "status.open": "Rozbalit tento toot", "status.pin": "Připnout na profil", "status.pinned": "Připnutý toot", "status.read_more": "Číst více", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Místní", "tabs_bar.notifications": "Oznámení", "tabs_bar.search": "Hledat", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {člověk} few {lidé} many {lidí} other {lidí}} hovoří", "ui.beforeunload": "Váš koncept se ztratí, pokud Mastodon opustíte.", "upload_area.title": "Přetažením nahrajete", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index 95c8632f7..828508b2a 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Yn dilyn", "notifications.filter.mentions": "Crybwylliadau", "notifications.group": "{count} o hysbysiadau", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Addasu preifatrwdd y tŵt", "privacy.direct.long": "Cyhoeddi i'r defnyddwyr sy'n cael eu crybwyll yn unig", "privacy.direct.short": "Uniongyrchol", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lleol", "tabs_bar.notifications": "Hysbysiadau", "tabs_bar.search": "Chwilio", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} yn siarad", "ui.beforeunload": "Mi fyddwch yn colli eich drafft os gadewch Mastodon.", "upload_area.title": "Llusgwch & gollwing i uwchlwytho", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index f383f2c9c..7e8f4d3f7 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Følger", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifikationer", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Ændre status privatliv", "privacy.direct.long": "Post til kun de nævnte brugere", "privacy.direct.short": "Direkte", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Notifikationer", "tabs_bar.search": "Søg", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} snakker", "ui.beforeunload": "Din kladde vil gå tabt hvis du forlader Mastodon.", "upload_area.title": "Træk og slip for at uploade", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 18e496b0e..44d8e76fa 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Folgende", "notifications.filter.mentions": "Erwähnungen", "notifications.group": "{count} Benachrichtigungen", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Sichtbarkeit des Beitrags anpassen", "privacy.direct.long": "Beitrag nur an erwähnte Profile", "privacy.direct.short": "Direkt", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Mitteilungen", "tabs_bar.search": "Suchen", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, eine {Person} other {Personen}} reden darüber", "ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.", "upload_area.title": "Zum Hochladen hereinziehen", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 55de86625..868e54751 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -200,6 +200,47 @@ { "descriptors": [ { + "defaultMessage": "Moments remaining", + "id": "time_remaining.moments" + }, + { + "defaultMessage": "{number, plural, one {# second} other {# seconds}} left", + "id": "time_remaining.seconds" + }, + { + "defaultMessage": "{number, plural, one {# minute} other {# minutes}} left", + "id": "time_remaining.minutes" + }, + { + "defaultMessage": "{number, plural, one {# hour} other {# hours}} left", + "id": "time_remaining.hours" + }, + { + "defaultMessage": "{number, plural, one {# day} other {# days}} left", + "id": "time_remaining.days" + }, + { + "defaultMessage": "Closed", + "id": "poll.closed" + }, + { + "defaultMessage": "Vote", + "id": "poll.vote" + }, + { + "defaultMessage": "Refresh", + "id": "poll.refresh" + }, + { + "defaultMessage": "{count, plural, one {# vote} other {# votes}}", + "id": "poll.total_votes" + } + ], + "path": "app/javascript/mastodon/components/poll.json" + }, + { + "descriptors": [ + { "defaultMessage": "now", "id": "relative_time.just_now" }, diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index a36f41ce5..a9ed36243 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "και {additional}", "hashtag.column_header.tag_mode.any": "ή {additional}", "hashtag.column_header.tag_mode.none": "χωρίς {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "Δεν βρέθηκαν προτάσεις", + "hashtag.column_settings.select.placeholder": "Γράψε μερικές ταμπέλες…", "hashtag.column_settings.tag_mode.all": "Όλα αυτα", "hashtag.column_settings.tag_mode.any": "Οποιοδήποτε από αυτά", "hashtag.column_settings.tag_mode.none": "Κανένα από αυτά", @@ -206,7 +206,7 @@ "lists.account.remove": "Βγάλε από τη λίστα", "lists.delete": "Διαγραφή λίστας", "lists.edit": "Επεξεργασία λίστας", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Αλλαγή τίτλου", "lists.new.create": "Προσθήκη λίστας", "lists.new.title_placeholder": "Τίτλος νέας λίστα", "lists.search": "Αναζήτησε μεταξύ των ανθρώπων που ακουλουθείς", @@ -260,6 +260,10 @@ "notifications.filter.follows": "Ακόλουθοι", "notifications.filter.mentions": "Αναφορές", "notifications.group": "{count} ειδοποιήσεις", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Προσαρμογή ιδιωτικότητας δημοσίευσης", "privacy.direct.long": "Δημοσίευση μόνο σε όσους και όσες αναφέρονται", "privacy.direct.short": "Προσωπικά", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Τοπικά", "tabs_bar.notifications": "Ειδοποιήσεις", "tabs_bar.search": "Αναζήτηση", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} μιλάνε", "ui.beforeunload": "Το προσχέδιό σου θα χαθεί αν φύγεις από το Mastodon.", "upload_area.title": "Drag & drop για να ανεβάσεις", diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index c05018839..ae37322e8 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -265,6 +265,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -347,6 +351,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index f8d427c80..47820da90 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Sekvoj", "notifications.filter.mentions": "Mencioj", "notifications.group": "{count} sciigoj", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Agordi mesaĝan privatecon", "privacy.direct.long": "Afiŝi nur al menciitaj uzantoj", "privacy.direct.short": "Rekta", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Loka tempolinio", "tabs_bar.notifications": "Sciigoj", "tabs_bar.search": "Serĉi", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, pluraj, unu {person} alia(j) {people}} parolas", "ui.beforeunload": "Via malneto perdiĝos se vi eliras de Mastodon.", "upload_area.title": "Altreni kaj lasi por alŝuti", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 99dce8ffe..7bb1a304e 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notificaciones", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Ajustar privacidad", "privacy.direct.long": "Sólo mostrar a los usuarios mencionados", "privacy.direct.short": "Directo", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificaciones", "tabs_bar.search": "Buscar", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Tu borrador se perderá si sales de Mastodon.", "upload_area.title": "Arrastra y suelta para subir", diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json index 63a2354ae..76f1c24f0 100644 --- a/app/javascript/mastodon/locales/eu.json +++ b/app/javascript/mastodon/locales/eu.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Jarraipenak", "notifications.filter.mentions": "Aipamenak", "notifications.group": "{count} jakinarazpen", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Doitu mezuaren pribatutasuna", "privacy.direct.long": "Bidali aipatutako erabiltzaileei besterik ez", "privacy.direct.short": "Zuzena", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokala", "tabs_bar.notifications": "Jakinarazpenak", "tabs_bar.search": "Bilatu", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} hitz egiten", "ui.beforeunload": "Zure zirriborroa galduko da Mastodon uzten baduzu.", "upload_area.title": "Arrastatu eta jaregin igotzeko", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index f2f144e78..5cdcf2441 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -258,8 +258,12 @@ "notifications.filter.boosts": "بازبوقها", "notifications.filter.favourites": "پسندیدهها", "notifications.filter.follows": "پیگیریها", - "notifications.filter.mentions": "نامبردنها", + "notifications.filter.mentions": "گفتگوها", "notifications.group": "{count} اعلان", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "تنظیم حریم خصوصی نوشتهها", "privacy.direct.long": "تنها به کاربران نامبردهشده نشان بده", "privacy.direct.short": "مستقیم", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "محلی", "tabs_bar.notifications": "اعلانها", "tabs_bar.search": "جستجو", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {نفر نوشته است} other {نفر نوشتهاند}}", "ui.beforeunload": "اگر از ماستدون خارج شوید پیشنویس شما پاک خواهد شد.", "upload_area.title": "برای بارگذاری به اینجا بکشید", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 537280223..6ddd5a02d 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Säädä tuuttauksen näkyvyyttä", "privacy.direct.long": "Julkaise vain mainituille käyttäjille", "privacy.direct.short": "Suora viesti", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Paikallinen", "tabs_bar.notifications": "Ilmoitukset", "tabs_bar.search": "Hae", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Luonnos häviää, jos poistut Mastodonista.", "upload_area.title": "Lataa raahaamalla ja pudottamalla tähän", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index f88f29b11..91ac04fcd 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "et {additional}", "hashtag.column_header.tag_mode.any": "ou {additional}", "hashtag.column_header.tag_mode.none": "sans {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "Aucune suggestion trouvée", + "hashtag.column_settings.select.placeholder": "Ajouter des hashtags…", "hashtag.column_settings.tag_mode.all": "Tous ces éléments", "hashtag.column_settings.tag_mode.any": "Au moins un de ces éléments", "hashtag.column_settings.tag_mode.none": "Aucun de ces éléments", @@ -206,7 +206,7 @@ "lists.account.remove": "Supprimer de la liste", "lists.delete": "Effacer la liste", "lists.edit": "Éditer la liste", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Changer le titre", "lists.new.create": "Ajouter une liste", "lists.new.title_placeholder": "Titre de la nouvelle liste", "lists.search": "Rechercher parmi les gens que vous suivez", @@ -260,6 +260,10 @@ "notifications.filter.follows": "Abonné·e·s", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Ajuster la confidentialité du message", "privacy.direct.long": "N’envoyer qu’aux personnes mentionnées", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Fil public local", "tabs_bar.notifications": "Notifications", "tabs_bar.search": "Chercher", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {personne} other {personnes}} discutent", "ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.", "upload_area.title": "Glissez et déposez pour envoyer", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 789624d38..29638d348 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Seguimentos", "notifications.filter.mentions": "Mencións", "notifications.group": "{count} notificacións", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Axustar a intimidade do estado", "privacy.direct.long": "Enviar exclusivamente as usuarias mencionadas", "privacy.direct.short": "Directa", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificacións", "tabs_bar.search": "Buscar", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} outras {people}} conversando", "ui.beforeunload": "O borrador perderase se sae de Mastodon.", "upload_area.title": "Arrastre e solte para subir", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 56d474170..d40e339a8 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "שינוי פרטיות ההודעה", "privacy.direct.long": "הצג רק למי שהודעה זו פונה אליו", "privacy.direct.short": "הודעה ישירה", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "ציר זמן מקומי", "tabs_bar.notifications": "התראות", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "הטיוטא תאבד אם תעזבו את מסטודון.", "upload_area.title": "ניתן להעלות על ידי Drag & drop", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index cc4f5725a..b17aa8058 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Podesi status privatnosti", "privacy.direct.long": "Prikaži samo spomenutim korisnicima", "privacy.direct.short": "Direktno", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokalno", "tabs_bar.notifications": "Notifikacije", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Povuci i spusti kako bi uploadao", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index a82b3e94d..f0c686212 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Státusz láthatóságának módosítása", "privacy.direct.long": "Posztolás csak az említett felhasználóknak", "privacy.direct.short": "Egyenesen", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Értesítések", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "A piszkozata el fog vesztődni ha elhagyja Mastodon-t.", "upload_area.title": "Húzza ide a feltöltéshez", diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json index 07b239f87..f9ef89fa9 100644 --- a/app/javascript/mastodon/locales/hy.json +++ b/app/javascript/mastodon/locales/hy.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Կարգավորել թթի գաղտնիությունը", "privacy.direct.long": "Թթել միայն նշված օգտատերերի համար", "privacy.direct.short": "Հասցեագրված", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Տեղական", "tabs_bar.notifications": "Ծանուցումներ", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Քո սեւագիրը կկորի, եթե լքես Մաստոդոնը։", "upload_area.title": "Քաշիր ու նետիր՝ վերբեռնելու համար", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index a23c0a547..3f6c420a6 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Tentukan privasi status", "privacy.direct.long": "Kirim hanya ke pengguna yang disebut", "privacy.direct.short": "Langsung", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Notifikasi", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Naskah anda akan hilang jika anda keluar dari Mastodon.", "upload_area.title": "Seret & lepaskan untuk mengunggah", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index e375314bd..3b7d86ab0 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Aranjar privateso di mesaji", "privacy.direct.long": "Sendar nur a mencionata uzeri", "privacy.direct.short": "Direte", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokala", "tabs_bar.notifications": "Savigi", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Tranar faligar por kargar", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 6825d8d05..8be3e6163 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "e {additional}", "hashtag.column_header.tag_mode.any": "o {additional}", "hashtag.column_header.tag_mode.none": "senza {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "Nessun suggerimento trovato", + "hashtag.column_settings.select.placeholder": "Inserisci hashtag…", "hashtag.column_settings.tag_mode.all": "Tutti questi", "hashtag.column_settings.tag_mode.any": "Uno o più di questi", "hashtag.column_settings.tag_mode.none": "Nessuno di questi", @@ -206,7 +206,7 @@ "lists.account.remove": "Togli dalla lista", "lists.delete": "Delete list", "lists.edit": "Modifica lista", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Cambia titolo", "lists.new.create": "Aggiungi lista", "lists.new.title_placeholder": "Titolo della nuova lista", "lists.search": "Cerca tra le persone che segui", @@ -227,7 +227,7 @@ "navigation_bar.favourites": "Apprezzati", "navigation_bar.filters": "Parole silenziate", "navigation_bar.follow_requests": "Richieste di amicizia", - "navigation_bar.info": "Informazioni estese", + "navigation_bar.info": "Informazioni su questo server", "navigation_bar.keyboard_shortcuts": "Tasti di scelta rapida", "navigation_bar.lists": "Liste", "navigation_bar.logout": "Esci", @@ -260,6 +260,10 @@ "notifications.filter.follows": "Seguaci", "notifications.filter.mentions": "Menzioni", "notifications.group": "{count} notifiche", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Modifica privacy del post", "privacy.direct.long": "Invia solo a utenti menzionati", "privacy.direct.short": "Diretto", @@ -279,7 +283,7 @@ "reply_indicator.cancel": "Annulla", "report.forward": "Inoltra a {target}", "report.forward_hint": "Questo account appartiene a un altro server. Mandare anche là una copia anonima del rapporto?", - "report.hint": "La segnalazione sarà inviata ai moderatori della tua istanza. Di seguito, puoi fornire il motivo per il quale stai segnalando questo account:", + "report.hint": "La segnalazione sarà inviata ai moderatori del tuo server. Di seguito, puoi fornire il motivo per il quale stai segnalando questo account:", "report.placeholder": "Commenti aggiuntivi", "report.submit": "Invia", "report.target": "Invio la segnalazione {target}", @@ -297,10 +301,10 @@ "standalone.public_title": "Un'occhiata all'interno...", "status.admin_account": "Apri interfaccia di moderazione per @{name}", "status.admin_status": "Apri questo status nell'interfaccia di moderazione", - "status.block": "Block @{name}", + "status.block": "Blocca @{name}", "status.cancel_reblog_private": "Annulla condivisione", "status.cannot_reblog": "Questo post non può essere condiviso", - "status.copy": "Copy link to status", + "status.copy": "Copia link allo status", "status.delete": "Elimina", "status.detailed_status": "Vista conversazione dettagliata", "status.direct": "Messaggio diretto @{name}", @@ -342,11 +346,16 @@ "tabs_bar.local_timeline": "Locale", "tabs_bar.notifications": "Notifiche", "tabs_bar.search": "Cerca", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {persona ne sta} other {persone ne stanno}} parlando", "ui.beforeunload": "La bozza andrà persa se esci da Mastodon.", "upload_area.title": "Trascina per caricare", "upload_button.label": "Aggiungi file multimediale", - "upload_error.limit": "File upload limit exceeded.", + "upload_error.limit": "Limite al caricamento di file superato.", "upload_form.description": "Descrizione per utenti con disabilità visive", "upload_form.focus": "Modifica anteprima", "upload_form.undo": "Cancella", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 24d8ba11c..e2d3a98f3 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -146,8 +146,8 @@ "hashtag.column_header.tag_mode.all": "と {additional}", "hashtag.column_header.tag_mode.any": "か {additional}", "hashtag.column_header.tag_mode.none": "({additional} を除く)", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "提案はありません", + "hashtag.column_settings.select.placeholder": "ハッシュタグを入力してください…", "hashtag.column_settings.tag_mode.all": "すべてを含む", "hashtag.column_settings.tag_mode.any": "いずれかを含む", "hashtag.column_settings.tag_mode.none": "これらを除く", @@ -210,7 +210,7 @@ "lists.account.remove": "リストから外す", "lists.delete": "リストを削除", "lists.edit": "リストを編集", - "lists.edit.submit": "Change title", + "lists.edit.submit": "タイトルを変更", "lists.new.create": "リストを作成", "lists.new.title_placeholder": "新規リスト名", "lists.search": "フォローしている人の中から検索", @@ -265,6 +265,10 @@ "notifications.filter.follows": "フォロー", "notifications.filter.mentions": "返信", "notifications.group": "{count} 件の通知", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "公開範囲を変更", "privacy.direct.long": "メンションしたユーザーだけに公開", "privacy.direct.short": "ダイレクト", @@ -347,6 +351,11 @@ "tabs_bar.local_timeline": "ローカル", "tabs_bar.notifications": "通知", "tabs_bar.search": "検索", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {人} other {人}} がトゥート", "ui.beforeunload": "Mastodonから離れると送信前の投稿は失われます。", "upload_area.title": "ドラッグ&ドロップでアップロード", diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json index 2f7cdc70d..2821d75e4 100644 --- a/app/javascript/mastodon/locales/ka.json +++ b/app/javascript/mastodon/locales/ka.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} შეტყობინება", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "სტატუსის კონფიდენციალურობის მითითება", "privacy.direct.long": "დაიპოსტოს მხოლოდ დასახელებულ მომხმარებლებთან", "privacy.direct.short": "პირდაპირი", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "ლოკალური", "tabs_bar.notifications": "შეტყობინებები", "tabs_bar.search": "ძებნა", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} საუბრობს", "ui.beforeunload": "თქვენი დრაფტი გაუქმდება თუ დატოვებთ მასტოდონს.", "upload_area.title": "გადმოწიეთ და ჩააგდეთ ასატვირთათ", diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json index fc0c88dd9..529459cf2 100644 --- a/app/javascript/mastodon/locales/kk.json +++ b/app/javascript/mastodon/locales/kk.json @@ -1,363 +1,372 @@ { - "account.add_or_remove_from_list": "Add or Remove from lists", - "account.badges.bot": "Bot", - "account.block": "Block @{name}", - "account.block_domain": "Hide everything from {domain}", - "account.blocked": "Blocked", - "account.direct": "Direct message @{name}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", - "account.domain_blocked": "Domain hidden", - "account.edit_profile": "Edit profile", - "account.endorse": "Feature on profile", - "account.follow": "Follow", - "account.followers": "Followers", - "account.followers.empty": "No one follows this user yet.", - "account.follows": "Follows", - "account.follows.empty": "This user doesn't follow anyone yet.", - "account.follows_you": "Follows you", - "account.hide_reblogs": "Hide boosts from @{name}", - "account.link_verified_on": "Ownership of this link was checked on {date}", - "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.", - "account.media": "Media", - "account.mention": "Mention @{name}", - "account.moved_to": "{name} has moved to:", - "account.mute": "Mute @{name}", - "account.mute_notifications": "Mute notifications from @{name}", - "account.muted": "Muted", - "account.posts": "Toots", - "account.posts_with_replies": "Toots and replies", - "account.report": "Report @{name}", - "account.requested": "Awaiting approval. Click to cancel follow request", - "account.share": "Share @{name}'s profile", - "account.show_reblogs": "Show boosts from @{name}", - "account.unblock": "Unblock @{name}", - "account.unblock_domain": "Unhide {domain}", - "account.unendorse": "Don't feature on profile", - "account.unfollow": "Unfollow", - "account.unmute": "Unmute @{name}", - "account.unmute_notifications": "Unmute notifications from @{name}", - "account.view_full_profile": "View full profile", - "alert.unexpected.message": "An unexpected error occurred.", - "alert.unexpected.title": "Oops!", - "boost_modal.combo": "You can press {combo} to skip this next time", - "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": "Blocked users", - "column.community": "Local timeline", - "column.direct": "Direct messages", - "column.domain_blocks": "Hidden domains", - "column.favourites": "Favourites", - "column.follow_requests": "Follow requests", - "column.home": "Home", - "column.lists": "Lists", - "column.mutes": "Muted users", + "account.add_or_remove_from_list": "Тізімге қосу немесе жою", + "account.badges.bot": "Бот", + "account.block": "Бұғаттау @{name}", + "account.block_domain": "Домендегі барлығын бұғатта {domain}", + "account.blocked": "Бұғатталды", + "account.direct": "Жеке хат @{name}", + "account.disclaimer_full": "Қолданушы туралы барлық мәліметті көрсетпеуі мүмкін.", + "account.domain_blocked": "Домен жабық", + "account.edit_profile": "Профильді өңдеу", + "account.endorse": "Профильде рекомендеу", + "account.follow": "Жазылу", + "account.followers": "Оқырмандар", + "account.followers.empty": "Әлі ешкім жазылмаған.", + "account.follows": "Жазылғандары", + "account.follows.empty": "Ешкімге жазылмапты.", + "account.follows_you": "Сізге жазылыпты", + "account.hide_reblogs": "@{name} атты қолданушының әрекеттерін жасыру", + "account.link_verified_on": "Сілтеме меншігі расталған күн {date}", + "account.locked_info": "Бұл қолданушы өзі туралы мәліметтерді жасырған. Тек жазылғандар ғана көре алады.", + "account.media": "Медиа", + "account.mention": "Аталым @{name}", + "account.moved_to": "{name} көшіп кетті:", + "account.mute": "Үнсіз қылу @{name}", + "account.mute_notifications": "@{name} туралы ескертпелерді жасыру", + "account.muted": "Үнсіз", + "account.posts": "Жазбалар", + "account.posts_with_replies": "Жазбалар мен жауаптар", + "account.report": "Шағымдану @{name}", + "account.requested": "Растауын күтіңіз. Жазылудан бас тарту үшін басыңыз", + "account.share": "@{name} профилін бөлісу\"", + "account.show_reblogs": "@{name} бөліскендерін көрсету", + "account.unblock": "Бұғаттан шығару @{name}", + "account.unblock_domain": "Бұғаттан шығару {domain}", + "account.unendorse": "Профильде рекомендемеу", + "account.unfollow": "Оқымау", + "account.unmute": "@{name} ескертпелерін қосу", + "account.unmute_notifications": "@{name} ескертпелерін көрсету", + "account.view_full_profile": "Толқы профилін көрсету", + "alert.unexpected.message": "Бір нәрсе дұрыс болмады.", + "alert.unexpected.title": "Өй!", + "boost_modal.combo": "Келесіде өткізіп жіберу үшін басыңыз {combo}", + "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.direct": "Жеке хаттар", + "column.domain_blocks": "Жасырылған домендер", + "column.favourites": "Таңдаулылар", + "column.follow_requests": "Жазылу сұранымдары", + "column.home": "Басты бет", + "column.lists": "Тізімдер", + "column.mutes": "Үнсіз қолданушылар", "column.notifications": "Notifications", - "column.pins": "Pinned toot", - "column.public": "Federated timeline", - "column_back_button.label": "Back", - "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.settings": "Settings", - "community.column_settings.media_only": "Media Only", - "compose_form.direct_message_warning": "This toot will only be sent to all the mentioned users.", - "compose_form.direct_message_warning_learn_more": "Learn more", - "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", - "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", - "compose_form.lock_disclaimer.lock": "locked", - "compose_form.placeholder": "What is on your mind?", - "compose_form.publish": "Toot", + "column.pins": "Жабыстырылған жазбалар", + "column.public": "Жаһандық желі", + "column_back_button.label": "Артқа", + "column_header.hide_settings": "Баптауларды жасыр", + "column_header.moveLeft_settings": "Бағананы солға жылжыту", + "column_header.moveRight_settings": "Бағананы оңға жылжыту", + "column_header.pin": "Жабыстыру", + "column_header.show_settings": "Баптауларды көрсет", + "column_header.unpin": "Алып тастау", + "column_subheading.settings": "Баптаулар", + "community.column_settings.media_only": "Тек медиа", + "compose_form.direct_message_warning": "Тек аталған қолданушыларға.", + "compose_form.direct_message_warning_learn_more": "Көбірек білу", + "compose_form.hashtag_warning": "Бұл пост іздеуде хэштегпен шықпайды, өйткені ол бәріне ашық емес. Тек ашық жазбаларды ғана хэштег арқылы іздеп табуға болады.", + "compose_form.lock_disclaimer": "Аккаунтыңыз {locked} емес. Кез келген адам жазылып, сізді оқи алады.", + "compose_form.lock_disclaimer.lock": "жабық", + "compose_form.placeholder": "Не бөліскіңіз келеді?", + "compose_form.publish": "Түрт", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive.marked": "Media is marked as sensitive", - "compose_form.sensitive.unmarked": "Media is not marked as sensitive", - "compose_form.spoiler.marked": "Text is hidden behind warning", - "compose_form.spoiler.unmarked": "Text is not hidden", - "compose_form.spoiler_placeholder": "Write your warning here", - "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.delete_list.confirm": "Delete", - "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?", - "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. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", - "confirmations.mute.confirm": "Mute", - "confirmations.mute.message": "Are you sure you want to mute {name}?", - "confirmations.redraft.confirm": "Delete & redraft", - "confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.", - "confirmations.reply.confirm": "Reply", - "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", - "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.custom": "Custom", - "emoji_button.flags": "Flags", - "emoji_button.food": "Food & Drink", - "emoji_button.label": "Insert emoji", - "emoji_button.nature": "Nature", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", - "emoji_button.objects": "Objects", - "emoji_button.people": "People", - "emoji_button.recent": "Frequently used", - "emoji_button.search": "Search...", - "emoji_button.search_results": "Search results", - "emoji_button.symbols": "Symbols", - "emoji_button.travel": "Travel & Places", - "empty_column.account_timeline": "No toots here!", - "empty_column.blocks": "You haven't blocked any users yet.", - "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", - "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", - "empty_column.domain_blocks": "There are no hidden domains yet.", - "empty_column.favourited_statuses": "You don't have any favourite toots yet. When you favourite one, it will show up here.", - "empty_column.favourites": "No one has favourited this toot yet. When someone does, they will show up here.", - "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", - "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", - "empty_column.home.public_timeline": "the public timeline", - "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.", - "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", - "empty_column.mutes": "You haven't muted any users yet.", - "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", - "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up", - "follow_request.authorize": "Authorize", - "follow_request.reject": "Reject", - "getting_started.developers": "Developers", - "getting_started.directory": "Profile directory", - "getting_started.documentation": "Documentation", - "getting_started.heading": "Getting started", - "getting_started.invite": "Invite people", - "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.", - "getting_started.security": "Security", - "getting_started.terms": "Terms of service", - "hashtag.column_header.tag_mode.all": "and {additional}", - "hashtag.column_header.tag_mode.any": "or {additional}", - "hashtag.column_header.tag_mode.none": "without {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", - "hashtag.column_settings.tag_mode.all": "All of these", - "hashtag.column_settings.tag_mode.any": "Any of these", - "hashtag.column_settings.tag_mode.none": "None of these", - "hashtag.column_settings.tag_toggle": "Include additional tags in this column", - "home.column_settings.basic": "Basic", - "home.column_settings.show_reblogs": "Show boosts", - "home.column_settings.show_replies": "Show replies", - "introduction.federation.action": "Next", - "introduction.federation.federated.headline": "Federated", - "introduction.federation.federated.text": "Public posts from other servers of the fediverse will appear in the federated timeline.", - "introduction.federation.home.headline": "Home", - "introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!", - "introduction.federation.local.headline": "Local", - "introduction.federation.local.text": "Public posts from people on the same server as you will appear in the local timeline.", - "introduction.interactions.action": "Finish toot-orial!", - "introduction.interactions.favourite.headline": "Favourite", - "introduction.interactions.favourite.text": "You can save a toot for later, and let the author know that you liked it, by favouriting it.", - "introduction.interactions.reblog.headline": "Boost", - "introduction.interactions.reblog.text": "You can share other people's toots with your followers by boosting them.", - "introduction.interactions.reply.headline": "Reply", - "introduction.interactions.reply.text": "You can reply to other people's and your own toots, which will chain them together in a conversation.", - "introduction.welcome.action": "Let's go!", - "introduction.welcome.headline": "First steps", - "introduction.welcome.text": "Welcome to the fediverse! In a few moments, you'll be able to broadcast messages and talk to your friends across a wide variety of servers. But this server, {domain}, is special—it hosts your profile, so remember its name.", - "keyboard_shortcuts.back": "to navigate back", - "keyboard_shortcuts.blocked": "to open blocked users list", - "keyboard_shortcuts.boost": "to boost", - "keyboard_shortcuts.column": "to focus a status in one of the columns", - "keyboard_shortcuts.compose": "to focus the compose textarea", - "keyboard_shortcuts.description": "Description", - "keyboard_shortcuts.direct": "to open direct messages column", - "keyboard_shortcuts.down": "to move down in the list", - "keyboard_shortcuts.enter": "to open status", - "keyboard_shortcuts.favourite": "to favourite", - "keyboard_shortcuts.favourites": "to open favourites list", - "keyboard_shortcuts.federated": "to open federated timeline", - "keyboard_shortcuts.heading": "Keyboard Shortcuts", - "keyboard_shortcuts.home": "to open home timeline", - "keyboard_shortcuts.hotkey": "Hotkey", - "keyboard_shortcuts.legend": "to display this legend", - "keyboard_shortcuts.local": "to open local timeline", - "keyboard_shortcuts.mention": "to mention author", - "keyboard_shortcuts.muted": "to open muted users list", - "keyboard_shortcuts.my_profile": "to open your profile", - "keyboard_shortcuts.notifications": "to open notifications column", - "keyboard_shortcuts.pinned": "to open pinned toots list", - "keyboard_shortcuts.profile": "to open author's profile", - "keyboard_shortcuts.reply": "to reply", - "keyboard_shortcuts.requests": "to open follow requests list", - "keyboard_shortcuts.search": "to focus search", - "keyboard_shortcuts.start": "to open \"get started\" column", - "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", - "keyboard_shortcuts.toot": "to start a brand new toot", - "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search", - "keyboard_shortcuts.up": "to move up in the list", - "lightbox.close": "Close", - "lightbox.next": "Next", - "lightbox.previous": "Previous", - "lists.account.add": "Add to list", - "lists.account.remove": "Remove from list", - "lists.delete": "Delete list", - "lists.edit": "Edit list", - "lists.edit.submit": "Change title", - "lists.new.create": "Add list", - "lists.new.title_placeholder": "New list title", - "lists.search": "Search among people you follow", - "lists.subheading": "Your lists", - "loading_indicator.label": "Loading...", - "media_gallery.toggle_visible": "Toggle visibility", - "missing_indicator.label": "Not found", - "missing_indicator.sublabel": "This resource could not be found", - "mute_modal.hide_notifications": "Hide notifications from this user?", - "navigation_bar.apps": "Mobile apps", - "navigation_bar.blocks": "Blocked users", - "navigation_bar.community_timeline": "Local timeline", - "navigation_bar.compose": "Compose new toot", - "navigation_bar.direct": "Direct messages", - "navigation_bar.discover": "Discover", - "navigation_bar.domain_blocks": "Hidden domains", - "navigation_bar.edit_profile": "Edit profile", - "navigation_bar.favourites": "Favourites", - "navigation_bar.filters": "Muted words", - "navigation_bar.follow_requests": "Follow requests", - "navigation_bar.info": "About this server", - "navigation_bar.keyboard_shortcuts": "Hotkeys", - "navigation_bar.lists": "Lists", - "navigation_bar.logout": "Logout", - "navigation_bar.mutes": "Muted users", - "navigation_bar.personal": "Personal", - "navigation_bar.pins": "Pinned toots", - "navigation_bar.preferences": "Preferences", - "navigation_bar.public_timeline": "Federated timeline", - "navigation_bar.security": "Security", - "notification.favourite": "{name} favourited your status", - "notification.follow": "{name} followed you", - "notification.mention": "{name} mentioned you", - "notification.reblog": "{name} boosted your status", - "notifications.clear": "Clear notifications", - "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", - "notifications.column_settings.alert": "Desktop notifications", - "notifications.column_settings.favourite": "Favourites:", - "notifications.column_settings.filter_bar.advanced": "Display all categories", - "notifications.column_settings.filter_bar.category": "Quick filter bar", - "notifications.column_settings.filter_bar.show": "Show", - "notifications.column_settings.follow": "New followers:", - "notifications.column_settings.mention": "Mentions:", - "notifications.column_settings.push": "Push notifications", - "notifications.column_settings.reblog": "Boosts:", - "notifications.column_settings.show": "Show in column", - "notifications.column_settings.sound": "Play sound", - "notifications.filter.all": "All", - "notifications.filter.boosts": "Boosts", - "notifications.filter.favourites": "Favourites", - "notifications.filter.follows": "Follows", - "notifications.filter.mentions": "Mentions", - "notifications.group": "{count} notifications", - "privacy.change": "Adjust status privacy", - "privacy.direct.long": "Post to mentioned users only", - "privacy.direct.short": "Direct", - "privacy.private.long": "Post to followers only", - "privacy.private.short": "Followers-only", - "privacy.public.long": "Post to public timelines", - "privacy.public.short": "Public", + "compose_form.sensitive.marked": "Медиа нәзік деп белгіленген", + "compose_form.sensitive.unmarked": "Медиа нәзік деп белгіленбеген", + "compose_form.spoiler.marked": "Мәтін ескертумен жасырылған", + "compose_form.spoiler.unmarked": "Мәтін жасырылмаған", + "compose_form.spoiler_placeholder": "Ескертуіңізді осында жазыңыз", + "confirmation_modal.cancel": "Қайтып алу", + "confirmations.block.confirm": "Бұғаттау", + "confirmations.block.message": "{name} атты қолданушыны бұғаттайтыныңызға сенімдісіз бе?", + "confirmations.delete.confirm": "Өшіру", + "confirmations.delete.message": "Бұл жазбаны өшіресіз бе?", + "confirmations.delete_list.confirm": "Өшіру", + "confirmations.delete_list.message": "Бұл тізімді жоясыз ба шынымен?", + "confirmations.domain_block.confirm": "Бұл доменді бұғатта", + "confirmations.domain_block.message": "Бұл домендегі {domain} жазбаларды шынымен бұғаттайсыз ба? Кейде үнсіз қылып тастау да жеткілікті.", + "confirmations.mute.confirm": "Үнсіз қылу", + "confirmations.mute.message": "{name} атты қолданушы үнсіз болсын ба?", + "confirmations.redraft.confirm": "Өшіруді құптау", + "confirmations.redraft.message": "Бұл жазбаны өшіріп, нобайларға жібереміз бе? Барлық жауаптар мен лайктарды жоғалтасыз.", + "confirmations.reply.confirm": "Жауап", + "confirmations.reply.message": "Жауабыңыз жазып жатқан жазбаңыздың үстіне кетеді. Жалғастырамыз ба?", + "confirmations.unfollow.confirm": "Оқымау", + "confirmations.unfollow.message": "\"{name} атты қолданушыға енді жазылғыңыз келмей ме?", + "embed.instructions": "Төмендегі кодты көшіріп алу арқылы жазбаны басқа сайттарға да орналастыра аласыз.", + "embed.preview": "Былай көрінетін болады:", + "emoji_button.activity": "Белсенділік", + "emoji_button.custom": "Жеке", + "emoji_button.flags": "Тулар", + "emoji_button.food": "Тамақ", + "emoji_button.label": "Эмодзи қосу", + "emoji_button.nature": "Табиғат", + "emoji_button.not_found": "Эмодзи жоқ!! (╯°□°)╯︵ ┻━┻", + "emoji_button.objects": "Заттар", + "emoji_button.people": "Адамдар", + "emoji_button.recent": "Соңғы", + "emoji_button.search": "Іздеу...", + "emoji_button.search_results": "Іздеу нәтижелері", + "emoji_button.symbols": "Таңбалар", + "emoji_button.travel": "Саяхат", + "empty_column.account_timeline": "Жазба жоқ ешқандай!", + "empty_column.blocks": "Ешкімді бұғаттамағансыз.", + "empty_column.community": "Жергілікті желі бос. Сіз бастап жазыңыз!", + "empty_column.direct": "Әзірше дым хат жоқ. Өзіңіз жазып көріңіз алдымен.", + "empty_column.domain_blocks": "Бұғатталған домен жоқ.", + "empty_column.favourited_statuses": "Ешқандай жазба 'Таңдаулылар' тізіміне қосылмапты. Қосылғаннан кейін осында жинала бастайды.", + "empty_column.favourites": "Бұл постты әлі ешкім 'Таңдаулылар' тізіміне қоспапты. Біреу бастағаннан кейін осында көрінетін болады.", + "empty_column.follow_requests": "Әлі ешқандай жазылуға сұранымдар келмеді. Жаңа сұранымдар осында көрінетін болады.", + "empty_column.hashtag": "Бұндай хэштегпен әлі ешкім жазбапты.", + "empty_column.home": "Әлі ешкімге жазылмапсыз. Бәлкім {public} жазбаларын қарап немесе іздеуді қолданып көрерсіз.", + "empty_column.home.public_timeline": "ашық желі", + "empty_column.list": "Бұл тізімде ештеңе жоқ.", + "empty_column.lists": "Әзірше ешқандай тізіміңіз жоқ. Біреуін құрғаннан кейін осы жерде көрінетін болады.", + "empty_column.mutes": "Әзірше ешқандай үнсізге қойылған қолданушы жоқ.", + "empty_column.notifications": "Әзірше ешқандай ескертпе жоқ. Басқалармен араласуды бастаңыз және пікірталастарға қатысыңыз.", + "empty_column.public": "Ештеңе жоқ бұл жерде! Өзіңіз бастап жазып көріңіз немесе басқаларға жазылыңыз", + "follow_request.authorize": "Авторизация", + "follow_request.reject": "Қабылдамау", + "getting_started.developers": "Жасаушылар тобы", + "getting_started.directory": "Профильдер каталогы", + "getting_started.documentation": "Құжаттама", + "getting_started.heading": "Желіде", + "getting_started.invite": "Адам шақыру", + "getting_started.open_source_notice": "Mastodon - ашық кодты құрылым. Түзету енгізу немесе ұсыныстарды GitHub арқылы жасаңыз {github}.", + "getting_started.security": "Қауіпсіздік", + "getting_started.terms": "Қызмет көрсету шарттары", + "hashtag.column_header.tag_mode.all": "және {additional}", + "hashtag.column_header.tag_mode.any": "немесе {additional}", + "hashtag.column_header.tag_mode.none": "{additional} болмай", + "hashtag.column_settings.select.no_options_message": "Ұсыныстар табылмады", + "hashtag.column_settings.select.placeholder": "Хэштег жазыңыз…", + "hashtag.column_settings.tag_mode.all": "Осының барлығын", + "hashtag.column_settings.tag_mode.any": "Осылардың біреуін", + "hashtag.column_settings.tag_mode.none": "Бұлардың ешқайсысын", + "hashtag.column_settings.tag_toggle": "Осы бағанға қосымша тегтерді қосыңыз", + "home.column_settings.basic": "Негізгі", + "home.column_settings.show_reblogs": "Бөлісулерді көрсету", + "home.column_settings.show_replies": "Жауаптарды көрсету", + "introduction.federation.action": "Келесі", + "introduction.federation.federated.headline": "Жаһандық", + "introduction.federation.federated.text": "Жаһандық желідегі жазбалар осында көрінетін болады.", + "introduction.federation.home.headline": "Басты бет", + "introduction.federation.home.text": "Жазылған адамдарыңыздың жазбалары осында шығады. Кез келген серверден жазылуыңызға болады!", + "introduction.federation.local.headline": "Жергілікті", + "introduction.federation.local.text": "Жергілікті желіде жазылған жазбалар осында шығатын болады.", + "introduction.interactions.action": "Оқулық аяқталды!", + "introduction.interactions.favourite.headline": "Таңдаулы", + "introduction.interactions.favourite.text": "Жазбаларды таңдаулыға сақтауға болады, осылайша авторына ұнағанын білдіре аласыз.", + "introduction.interactions.reblog.headline": "Бөлісу", + "introduction.interactions.reblog.text": "Ұнаған жазбаларды өз оқырмандарыңызбен бөлісе аласыз.", + "introduction.interactions.reply.headline": "Жауап", + "introduction.interactions.reply.text": "Жазбаларға жауап жаза аласыз, осылайша пікірталас өрбітуіңізге болады.", + "introduction.welcome.action": "Кеттік!", + "introduction.welcome.headline": "Алғашқы қадамдар", + "introduction.welcome.text": "Желіге қош келдіңіз! Бірнеше минуттан кейін желіде жазба қалдырып, медиа бөлісіп, басқалармен пікірталасқа қатысып ортаға қосыла аласыз. . Бірақ бұл сервер {domain} - бұл ерекше, ол сіздің профиліңізді қояды, сондықтан оның есімін есіңізде сақтаңыз.", + "keyboard_shortcuts.back": "артқа қайту", + "keyboard_shortcuts.blocked": "бұғатталғандар тізімін ашу", + "keyboard_shortcuts.boost": "жазба бөлісу", + "keyboard_shortcuts.column": "бағандардағы жазбаны оқу", + "keyboard_shortcuts.compose": "пост жазу", + "keyboard_shortcuts.description": "Сипаттама", + "keyboard_shortcuts.direct": "жеке хаттар бағаны", + "keyboard_shortcuts.down": "тізімде төмен түсу", + "keyboard_shortcuts.enter": "жазбаны ашу", + "keyboard_shortcuts.favourite": "таңдаулыға қосу", + "keyboard_shortcuts.favourites": "таңдаулылар тізімін ашу", + "keyboard_shortcuts.federated": "жаңандық желіні ашу", + "keyboard_shortcuts.heading": "Қысқа кодтар тақтасы", + "keyboard_shortcuts.home": "жергілікті жазбаларды қарау", + "keyboard_shortcuts.hotkey": "Ыстық пернелер", + "keyboard_shortcuts.legend": "осы мазмұнды көрсету", + "keyboard_shortcuts.local": "жергілікті желіні ашу", + "keyboard_shortcuts.mention": "авторды атап өту", + "keyboard_shortcuts.muted": "үнсіздер тізімін ашу", + "keyboard_shortcuts.my_profile": "профиліңізді ашу", + "keyboard_shortcuts.notifications": "ескертпелер бағанын ашу", + "keyboard_shortcuts.pinned": "жабыстырылған жазбаларды көру", + "keyboard_shortcuts.profile": "автор профилін қарау", + "keyboard_shortcuts.reply": "жауап жазу", + "keyboard_shortcuts.requests": "жазылу сұранымдарын қарау", + "keyboard_shortcuts.search": "іздеу", + "keyboard_shortcuts.start": "бастапқы бағанға бару", + "keyboard_shortcuts.toggle_hidden": "жабық мәтінді CW ашу/жабу", + "keyboard_shortcuts.toot": "жаңа жазба бастау", + "keyboard_shortcuts.unfocus": "жазба қалдыру алаңынан шығу", + "keyboard_shortcuts.up": "тізімде жоғары шығу", + "lightbox.close": "Жабу", + "lightbox.next": "Келесі", + "lightbox.previous": "Алдыңғы", + "lists.account.add": "Тізімге қосу", + "lists.account.remove": "Тізімнен шығару", + "lists.delete": "Тізімді өшіру", + "lists.edit": "Тізімді өңдеу", + "lists.edit.submit": "Тақырыбын өзгерту", + "lists.new.create": "Тізім құру", + "lists.new.title_placeholder": "Жаңа тізім аты", + "lists.search": "Сіз іздеген адамдар арасында іздеу", + "lists.subheading": "Тізімдеріңіз", + "loading_indicator.label": "Жүктеу...", + "media_gallery.toggle_visible": "Көрінуді қосу", + "missing_indicator.label": "Табылмады", + "missing_indicator.sublabel": "Бұл ресурс табылмады", + "mute_modal.hide_notifications": "Бұл қолданушы ескертпелерін жасырамыз ба?", + "navigation_bar.apps": "Мобиль қосымшаларMobile apps", + "navigation_bar.blocks": "Бұғатталғандар", + "navigation_bar.community_timeline": "Жергілікті желі", + "navigation_bar.compose": "Жаңа жазба бастау", + "navigation_bar.direct": "Жеке хаттар", + "navigation_bar.discover": "шарлау", + "navigation_bar.domain_blocks": "Жабық домендер", + "navigation_bar.edit_profile": "Профиль түзету", + "navigation_bar.favourites": "Таңдаулылар", + "navigation_bar.filters": "Үнсіз сөздер", + "navigation_bar.follow_requests": "Жазылуға сұранғандар", + "navigation_bar.info": "Сервер туралы", + "navigation_bar.keyboard_shortcuts": "Ыстық пернелер", + "navigation_bar.lists": "Тізімдер", + "navigation_bar.logout": "Шығу", + "navigation_bar.mutes": "Үнсіз қолданушылар", + "navigation_bar.personal": "Жеке", + "navigation_bar.pins": "Жабыстырылғандар", + "navigation_bar.preferences": "Басымдықтар", + "navigation_bar.public_timeline": "Жаһандық желі", + "navigation_bar.security": "Қауіпсіздік", + "notification.favourite": "{name} жазбаңызды таңдаулыға қосты", + "notification.follow": "{name} сізге жазылды", + "notification.mention": "{name} сізді атап өтті", + "notification.reblog": "{name} жазбаңызды бөлісті", + "notifications.clear": "Ескертпелерді тазарт", + "notifications.clear_confirmation": "Шынымен барлық ескертпелерді өшіресіз бе?", + "notifications.column_settings.alert": "Үстел ескертпелері", + "notifications.column_settings.favourite": "Таңдаулылар:", + "notifications.column_settings.filter_bar.advanced": "Барлық категорияны көрсет", + "notifications.column_settings.filter_bar.category": "Жедел сүзгі", + "notifications.column_settings.filter_bar.show": "Көрсету", + "notifications.column_settings.follow": "Жаңа оқырмандар:", + "notifications.column_settings.mention": "Аталымдар:", + "notifications.column_settings.push": "Push ескертпелер", + "notifications.column_settings.reblog": "Бөлісулер:", + "notifications.column_settings.show": "Бағанда көрсет", + "notifications.column_settings.sound": "Дыбысын қос", + "notifications.filter.all": "Барлығы", + "notifications.filter.boosts": "Бөлісулер", + "notifications.filter.favourites": "Таңдаулылар", + "notifications.filter.follows": "Жазылулар", + "notifications.filter.mentions": "Аталымдар", + "notifications.group": "{count} ескертпе", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", + "privacy.change": "Құпиялылықты реттеу", + "privacy.direct.long": "Аталған адамдарға ғана көрінетін жазба", + "privacy.direct.short": "Тікелей", + "privacy.private.long": "Тек оқырмандарға арналған жазба", + "privacy.private.short": "Оқырмандарға ғана", + "privacy.public.long": "Ашық желіге жібер", + "privacy.public.short": "Ашық", "privacy.unlisted.long": "Do not show in public timelines", - "privacy.unlisted.short": "Unlisted", - "regeneration_indicator.label": "Loading…", - "regeneration_indicator.sublabel": "Your home feed is being prepared!", - "relative_time.days": "{number}d", - "relative_time.hours": "{number}h", - "relative_time.just_now": "now", - "relative_time.minutes": "{number}m", + "privacy.unlisted.short": "Тізімсіз", + "regeneration_indicator.label": "Жүктеу…", + "regeneration_indicator.sublabel": "Жергілікті желі құрылуда!", + "relative_time.days": "{number}күн", + "relative_time.hours": "{number}сағ", + "relative_time.just_now": "жаңа", + "relative_time.minutes": "{number}мин", "relative_time.seconds": "{number}s", - "reply_indicator.cancel": "Cancel", - "report.forward": "Forward to {target}", - "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?", - "report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:", - "report.placeholder": "Additional comments", - "report.submit": "Submit", - "report.target": "Report {target}", - "search.placeholder": "Search", - "search_popout.search_format": "Advanced search format", - "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", - "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "status", - "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", - "search_popout.tips.user": "user", - "search_results.accounts": "People", - "search_results.hashtags": "Hashtags", - "search_results.statuses": "Toots", + "reply_indicator.cancel": "Қайтып алу", + "report.forward": "Жіберу {target}", + "report.forward_hint": "Бұл аккаунт басқа серверден. Аноним шағым жібересіз бе?", + "report.hint": "Шағым сіздің модераторларға жіберіледі. Шағымның себептерін мына жерге жазуыңызға болады:", + "report.placeholder": "Қосымша пікірлер", + "report.submit": "Жіберу", + "report.target": "Шағымдану {target}", + "search.placeholder": "Іздеу", + "search_popout.search_format": "Кеңейтілген іздеу форматы", + "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, bоosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", + "search_popout.tips.hashtag": "хэштег", + "search_popout.tips.status": "статус", + "search_popout.tips.text": "Simple text returns matching display names, usernames аnd hashtags", + "search_popout.tips.user": "қолданушы", + "search_results.accounts": "Адамдар", + "search_results.hashtags": "Хэштегтер", + "search_results.statuses": "Жазбалар", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", - "standalone.public_title": "A look inside...", - "status.admin_account": "Open moderation interface for @{name}", - "status.admin_status": "Open this status in the moderation interface", - "status.block": "Block @{name}", - "status.cancel_reblog_private": "Unboost", - "status.cannot_reblog": "This post cannot be boosted", - "status.copy": "Copy link to status", - "status.delete": "Delete", - "status.detailed_status": "Detailed conversation view", - "status.direct": "Direct message @{name}", - "status.embed": "Embed", - "status.favourite": "Favourite", - "status.filtered": "Filtered", - "status.load_more": "Load more", - "status.media_hidden": "Media hidden", - "status.mention": "Mention @{name}", - "status.more": "More", - "status.mute": "Mute @{name}", - "status.mute_conversation": "Mute conversation", - "status.open": "Expand this status", - "status.pin": "Pin on profile", - "status.pinned": "Pinned toot", - "status.read_more": "Read more", - "status.reblog": "Boost", - "status.reblog_private": "Boost to original audience", - "status.reblogged_by": "{name} boosted", - "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", - "status.redraft": "Delete & re-draft", - "status.reply": "Reply", - "status.replyAll": "Reply to thread", - "status.report": "Report @{name}", - "status.sensitive_toggle": "Click to view", - "status.sensitive_warning": "Sensitive content", - "status.share": "Share", - "status.show_less": "Show less", - "status.show_less_all": "Show less for all", - "status.show_more": "Show more", - "status.show_more_all": "Show more for all", - "status.show_thread": "Show thread", - "status.unmute_conversation": "Unmute conversation", - "status.unpin": "Unpin from profile", - "suggestions.dismiss": "Dismiss suggestion", - "suggestions.header": "You might be interested in…", - "tabs_bar.federated_timeline": "Federated", - "tabs_bar.home": "Home", - "tabs_bar.local_timeline": "Local", - "tabs_bar.notifications": "Notifications", - "tabs_bar.search": "Search", - "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", - "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", - "upload_area.title": "Drag & drop to upload", - "upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)", - "upload_error.limit": "File upload limit exceeded.", - "upload_form.description": "Describe for the visually impaired", - "upload_form.focus": "Crop", - "upload_form.undo": "Delete", - "upload_progress.label": "Uploading...", - "video.close": "Close video", - "video.exit_fullscreen": "Exit full screen", - "video.expand": "Expand video", - "video.fullscreen": "Full screen", - "video.hide": "Hide video", - "video.mute": "Mute sound", - "video.pause": "Pause", - "video.play": "Play", - "video.unmute": "Unmute sound" + "standalone.public_title": "Ішкі көрініс...", + "status.admin_account": "@{name} үшін модерация интерфейсін аш", + "status.admin_status": "Бұл жазбаны модерация интерфейсінде аш", + "status.block": "Бұғаттау @{name}", + "status.cancel_reblog_private": "Бөліспеу", + "status.cannot_reblog": "Бұл жазба бөлісілмейді", + "status.copy": "Жазба сілтемесін көшір", + "status.delete": "Өшіру", + "status.detailed_status": "Толық пікірталас көрінісі", + "status.direct": "Хат жіберу @{name}", + "status.embed": "Embеd", + "status.favourite": "Таңдаулы", + "status.filtered": "Фильтрленген", + "status.load_more": "Тағы әкел", + "status.media_hidden": "Жабық медиа", + "status.mention": "Аталым @{name}", + "status.more": "Тағы", + "status.mute": "Үнсіз @{name}", + "status.mute_conversation": "Пікірталасты үнсіз қылу", + "status.open": "Жазбаны ашу", + "status.pin": "Профильде жабыстыру", + "status.pinned": "Жабыстырылған жазба", + "status.read_more": "Әрі қарай", + "status.reblog": "Бөлісу", + "status.reblog_private": "Негізгі аудиторияға бөлісу", + "status.reblogged_by": "{name} бөлісті", + "status.reblogs.empty": "Бұл жазбаны әлі ешкім бөліспеді. Біреу бөліскен кезде осында көрінеді.", + "status.redraft": "Өшіру & қайта қарастыру", + "status.reply": "Жауап", + "status.replyAll": "Тақырыпқа жауап", + "status.report": "Шағым @{name}", + "status.sensitive_toggle": "Қарау үшін басыңыз", + "status.sensitive_warning": "Нәзік контент", + "status.share": "Бөлісу", + "status.show_less": "Аздап көрсет", + "status.show_less_all": "Бәрін аздап көрсет", + "status.show_more": "Толығырақ", + "status.show_more_all": "Бәрін толығымен", + "status.show_thread": "Желіні көрсет", + "status.unmute_conversation": "Пікірталасты үнсіз қылмау", + "status.unpin": "Профильден алып тастау", + "suggestions.dismiss": "Өткізіп жіберу", + "suggestions.header": "Қызығуыңыз мүмкін…", + "tabs_bar.federated_timeline": "Жаһандық", + "tabs_bar.home": "Басты бет", + "tabs_bar.local_timeline": "Жергілікті", + "tabs_bar.notifications": "Ескертпелер", + "tabs_bar.search": "Іздеу", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", + "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} жазған екен", + "ui.beforeunload": "Mastodon желісінен шықсаңыз, нобайыңыз сақталмайды.", + "upload_area.title": "Жүктеу үшін сүйреп әкеліңіз", + "upload_button.label": "Медиа қосу (JPEG, PNG, GIF, WebM, MP4, MOV)", + "upload_error.limit": "Файл жүктеу лимитінен асып кеттіңіз.", + "upload_form.description": "Көру қабілеті нашар адамдар үшін сипаттаңыз", + "upload_form.focus": "Превьюді өзгерту", + "upload_form.undo": "Өшіру", + "upload_progress.label": "Жүктеп жатыр...", + "video.close": "Видеоны жабу", + "video.exit_fullscreen": "Толық экраннан шық", + "video.expand": "Видеоны аш", + "video.fullscreen": "Толық экран", + "video.hide": "Видеоны жасыр", + "video.mute": "Дыбысын бас", + "video.pause": "Пауза", + "video.play": "Қосу", + "video.unmute": "Дауысын аш" } diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 040ada2c0..6363e2de7 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "그리고 {additional}", "hashtag.column_header.tag_mode.any": "또는 {additional}", "hashtag.column_header.tag_mode.none": "({additional}를 제외)", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "추천 할 내용이 없습니다", + "hashtag.column_settings.select.placeholder": "해시태그를 입력하세요…", "hashtag.column_settings.tag_mode.all": "모두", "hashtag.column_settings.tag_mode.any": "아무것이든", "hashtag.column_settings.tag_mode.none": "이것들을 제외하고", @@ -206,7 +206,7 @@ "lists.account.remove": "리스트에서 제거", "lists.delete": "리스트 삭제", "lists.edit": "리스트 편집", - "lists.edit.submit": "Change title", + "lists.edit.submit": "제목 수정", "lists.new.create": "리스트 추가", "lists.new.title_placeholder": "새 리스트의 이름", "lists.search": "팔로우 중인 사람들 중에서 찾기", @@ -260,6 +260,10 @@ "notifications.filter.follows": "팔로우", "notifications.filter.mentions": "멘션", "notifications.group": "{count} 개의 알림", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "포스트의 프라이버시 설정을 변경", "privacy.direct.long": "멘션한 사용자에게만 공개", "privacy.direct.short": "다이렉트", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "로컬", "tabs_bar.notifications": "알림", "tabs_bar.search": "검색", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {명} other {명}} 의 사람들이 말하고 있습니다", "ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.", "upload_area.title": "드래그 & 드롭으로 업로드", diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json index eaf3366aa..821d8c4b1 100644 --- a/app/javascript/mastodon/locales/lv.json +++ b/app/javascript/mastodon/locales/lv.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json index 536f65462..21f066439 100644 --- a/app/javascript/mastodon/locales/ms.json +++ b/app/javascript/mastodon/locales/ms.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index fdc1eed4f..f6d1041a0 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -142,8 +142,8 @@ "hashtag.column_header.tag_mode.all": "en {additional}", "hashtag.column_header.tag_mode.any": "of {additional}", "hashtag.column_header.tag_mode.none": "zonder {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "Geen voorstellen gevonden", + "hashtag.column_settings.select.placeholder": "Vul hashtags in…", "hashtag.column_settings.tag_mode.all": "Allemaal", "hashtag.column_settings.tag_mode.any": "Een van deze", "hashtag.column_settings.tag_mode.none": "Geen van deze", @@ -206,7 +206,7 @@ "lists.account.remove": "Uit lijst verwijderen", "lists.delete": "Lijst verwijderen", "lists.edit": "Lijst bewerken", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Titel veranderen", "lists.new.create": "Lijst toevoegen", "lists.new.title_placeholder": "Naam nieuwe lijst", "lists.search": "Zoek naar mensen die je volgt", @@ -260,6 +260,10 @@ "notifications.filter.follows": "Die jij volgt", "notifications.filter.mentions": "Vermeldingen", "notifications.group": "{count} meldingen", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Zichtbaarheid toot aanpassen", "privacy.direct.long": "Alleen aan vermelde gebruikers tonen", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokaal", "tabs_bar.notifications": "Meldingen", "tabs_bar.search": "Zoeken", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {persoon praat} other {mensen praten}} hierover", "ui.beforeunload": "Je concept zal verloren gaan als je Mastodon verlaat.", "upload_area.title": "Hierin slepen om te uploaden", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index d9504c0c5..8b6060d5d 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Justér synlighet", "privacy.direct.long": "Post kun til nevnte brukere", "privacy.direct.short": "Direkte", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Varslinger", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Din kladd vil bli forkastet om du forlater Mastodon.", "upload_area.title": "Dra og slipp for å laste opp", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 5d4897e2b..5c5a583b6 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -128,7 +128,7 @@ "empty_column.lists": "Encara avètz pas cap de lista. Quand ne creetz una, apareisserà aquí.", "empty_column.mutes": "Encara avètz pas mes en silenci degun.", "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’autres servidors per garnir lo flux public", "follow_request.authorize": "Acceptar", "follow_request.reject": "Regetar", "getting_started.developers": "Desvelopaires", @@ -227,7 +227,7 @@ "navigation_bar.favourites": "Favorits", "navigation_bar.filters": "Mots ignorats", "navigation_bar.follow_requests": "Demandas d’abonament", - "navigation_bar.info": "Mai informacions", + "navigation_bar.info": "Tocant aqueste servidor", "navigation_bar.keyboard_shortcuts": "Acorchis clavièr", "navigation_bar.lists": "Listas", "navigation_bar.logout": "Desconnexion", @@ -260,6 +260,10 @@ "notifications.filter.follows": "Seguiments", "notifications.filter.mentions": "Mencions", "notifications.group": "{count} notificacions", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Ajustar la confidencialitat del messatge", "privacy.direct.long": "Mostrar pas qu’a las personas mencionadas", "privacy.direct.short": "Dirècte", @@ -279,7 +283,7 @@ "reply_indicator.cancel": "Anullar", "report.forward": "Far sègre a {target}", "report.forward_hint": "Lo compte ven d’un autre servidor. Volètz mandar una còpia anonima del rapòrt enlai tanben ?", - "report.hint": "Lo moderator de l’instància aurà lo rapòrt. Podètz fornir una explicacion de vòstre senhalament aquí dejós :", + "report.hint": "Lo moderator del servidor aurà lo rapòrt. Podètz fornir una explicacion de vòstre senhalament aquí dejós :", "report.placeholder": "Comentaris addicionals", "report.submit": "Mandar", "report.target": "Senhalar {target}", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Flux public local", "tabs_bar.notifications": "Notificacions", "tabs_bar.search": "Recèrcas", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} ne charra other {people}} ne charran", "ui.beforeunload": "Vòstre brolhon serà perdut se quitatz Mastodon.", "upload_area.title": "Lisatz e depausatz per mandar", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index e5faa3689..d387aa87f 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -77,6 +77,10 @@ "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.poll.add_option": "Dodaj opcję", + "compose_form.poll.duration": "Czas trwania głosowania", + "compose_form.poll.option_placeholder": "Opcja {number}", + "compose_form.poll.remove_option": "Usuń tę opcję", "compose_form.publish": "Wyślij", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive.marked": "Zawartość multimedia jest oznaczona jako wrażliwa", @@ -146,8 +150,8 @@ "hashtag.column_header.tag_mode.all": "i {additional}", "hashtag.column_header.tag_mode.any": "lub {additional}", "hashtag.column_header.tag_mode.none": "bez {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "hashtag.column_settings.select.no_options_message": "Nie odnaleziono sugestii", + "hashtag.column_settings.select.placeholder": "Wprowadź hashtagi…", "hashtag.column_settings.tag_mode.all": "Wszystkie", "hashtag.column_settings.tag_mode.any": "Dowolne", "hashtag.column_settings.tag_mode.none": "Żadne", @@ -155,6 +159,9 @@ "home.column_settings.basic": "Podstawowe", "home.column_settings.show_reblogs": "Pokazuj podbicia", "home.column_settings.show_replies": "Pokazuj odpowiedzi", + "intervals.full.minutes": "{number, plural, one {# minuta} few {# minuty} many {# minut} other {# minut}}", + "intervals.full.hours": "{number, plural, one {# godzina} few {# godziny} many {# godzin} other {# godzin}}", + "intervals.full.days": "{number, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}", "introduction.federation.action": "Dalej", "introduction.federation.federated.headline": "Oś czasu federacji", "introduction.federation.federated.text": "Publiczne wpisy osób z tego całego Fediwersum pojawiają się na lokalnej osi czasu.", @@ -210,7 +217,7 @@ "lists.account.remove": "Usunąć z listy", "lists.delete": "Usuń listę", "lists.edit": "Edytuj listę", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Zmień tytuł", "lists.new.create": "Utwórz listę", "lists.new.title_placeholder": "Wprowadź tytuł listy", "lists.search": "Szukaj wśród osób które śledzisz", @@ -265,6 +272,12 @@ "notifications.filter.follows": "Śledzenia", "notifications.filter.mentions": "Wspomienia", "notifications.group": "{count, number} {count, plural, one {powiadomienie} few {powiadomienia} many {powiadomień} more {powiadomień}}", + "poll.closed": "Zamknięte", + "poll.refresh": "Odśwież", + "poll.total_votes": "{count, plural, one {# głos} few {# głosy} many {# głosów} other {# głosów}}", + "poll.vote": "Zagłosuj", + "poll_button.add_poll": "Dodaj głosowanie", + "poll_button.remove_poll": "Usuń głosowanie", "privacy.change": "Dostosuj widoczność wpisów", "privacy.direct.long": "Widoczny tylko dla wspomnianych", "privacy.direct.short": "Bezpośrednio", @@ -305,7 +318,7 @@ "status.block": "Zablokuj @{name}", "status.cancel_reblog_private": "Cofnij podbicie", "status.cannot_reblog": "Ten wpis nie może zostać podbity", - "status.copy": "Copy link to status", + "status.copy": "Skopiuj odnośnik do wpisu", "status.delete": "Usuń", "status.detailed_status": "Szczegółowy widok konwersacji", "status.direct": "Wyślij wiadomość bezpośrednią do @{name}", @@ -347,6 +360,11 @@ "tabs_bar.local_timeline": "Lokalne", "tabs_bar.notifications": "Powiadomienia", "tabs_bar.search": "Szukaj", + "time_remaining.days": "{number, plural, one {Pozostał # dzień} few {Pozostały # dni} many {Pozostało # dni} other {Pozostało # dni}}", + "time_remaining.hours": "{number, plural, one {Pozostała # godzina} few {Pozostały # godziny} many {Pozostało # godzin} other {Pozostało # godzin}}", + "time_remaining.minutes": "{number, plural, one {Pozostała # minuta} few {Pozostały # minuty} many {Pozostało # minut} other {Pozostało # minut}}", + "time_remaining.moments": "Pozostała chwila", + "time_remaining.seconds": "{number, plural, one {Pozostała # sekunda} few {Pozostały # sekundy} many {Pozostało # sekund} other {Pozostało # sekund}}", "trends.count_by_accounts": "{count} {rawCount, plural, one {osoba rozmawia} few {osoby rozmawiają} other {osób rozmawia}} o tym", "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.", "upload_area.title": "Przeciągnij i upuść aby wysłać", @@ -355,7 +373,7 @@ "upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących", "upload_form.focus": "Dopasuj podgląd", "upload_form.undo": "Usuń", - "upload_progress.label": "Wysyłanie...", + "upload_progress.label": "Wysyłanie…", "video.close": "Zamknij film", "video.exit_fullscreen": "Opuść tryb pełnoekranowy", "video.expand": "Rozszerz film", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 83c2dd0ce..368663a01 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Seguidores", "notifications.filter.mentions": "Menções", "notifications.group": "{count} notificações", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Ajustar a privacidade da mensagem", "privacy.direct.long": "Apenas para usuários mencionados", "privacy.direct.short": "Direta", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificações", "tabs_bar.search": "Buscar", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {pessoa} other {pessoas}} falando sobre", "ui.beforeunload": "Seu rascunho será perdido se você sair do Mastodon.", "upload_area.title": "Arraste e solte para enviar", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index af48b323c..c9a7cd6a3 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Ajustar a privacidade da mensagem", "privacy.direct.long": "Apenas para utilizadores mencionados", "privacy.direct.short": "Directo", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificações", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "O teu rascunho vai ser perdido se abandonares o Mastodon.", "upload_area.title": "Arraste e solte para enviar", diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json index 802e43ce2..a0d5f9a27 100644 --- a/app/javascript/mastodon/locales/ro.json +++ b/app/javascript/mastodon/locales/ro.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Urmărește", "notifications.filter.mentions": "Menționări", "notifications.group": "{count} notificări", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Cine vede asta", "privacy.direct.long": "Postează doar pentru utilizatorii menționați", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificări", "tabs_bar.search": "Căutare", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} vorbesc", "ui.beforeunload": "Postarea se va pierde dacă părăsești pagina.", "upload_area.title": "Trage și eliberează pentru a încărca", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 7c978bc3f..01c915d71 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} уведомл.", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Изменить видимость статуса", "privacy.direct.long": "Показать только упомянутым", "privacy.direct.short": "Направленный", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Локальная", "tabs_bar.notifications": "Уведомления", "tabs_bar.search": "Поиск", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "Популярно у {count} {rawCount, plural, one {человека} few {человек} many {человек} other {человек}}", "ui.beforeunload": "Ваш черновик будет утерян, если вы покинете Mastodon.", "upload_area.title": "Перетащите сюда, чтобы загрузить", diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index 62677471c..c11bebce8 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Sledovania", "notifications.filter.mentions": "Iba spomenutia", "notifications.group": "{count} oboznámení", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Uprav súkromie príspevku", "privacy.direct.long": "Pošli iba spomenutým používateľom", "privacy.direct.short": "Súkromne", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokálna", "tabs_bar.notifications": "Notifikácie", "tabs_bar.search": "Hľadaj", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {človek vraví} other {ľudia vravia}}", "ui.beforeunload": "Čo máš rozpísané sa stratí, ak opustíš Mastodon.", "upload_area.title": "Pretiahni a pusť pre nahratie", diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json index 213eb8203..b2404d178 100644 --- a/app/javascript/mastodon/locales/sl.json +++ b/app/javascript/mastodon/locales/sl.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokalno", "tabs_bar.notifications": "Obvestila", "tabs_bar.search": "Poišči", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Vaš osnutek bo izgubljen, če zapustite Mastodona.", "upload_area.title": "Povlecite in spustite za pošiljanje", diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 6e0d7ebb6..9aaec4b46 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Ndjekje", "notifications.filter.mentions": "Përmendje", "notifications.group": "%(count)s njoftime", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Rregulloni privatësi gjendje", "privacy.direct.long": "Postoja vetëm përdoruesve të përmendur", "privacy.direct.short": "I drejtpërdrejtë", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Vendore", "tabs_bar.notifications": "Njoftime", "tabs_bar.search": "Kërkim", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, një {person} {people} të tjerë} po flasin", "ui.beforeunload": "Skica juaj do të humbë nëse dilni nga Mastodon-i.", "upload_area.title": "Merreni & vëreni që të ngarkohet", diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json index 79619c914..59dc24ab3 100644 --- a/app/javascript/mastodon/locales/sr-Latn.json +++ b/app/javascript/mastodon/locales/sr-Latn.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Podesi status privatnosti", "privacy.direct.long": "Objavi samo korisnicima koji su pomenuti", "privacy.direct.short": "Direktno", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokalno", "tabs_bar.notifications": "Obaveštenja", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Ako napustite Mastodont, izgubićete napisani nacrt.", "upload_area.title": "Prevucite ovde da otpremite", diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json index b72431b34..1097d48fb 100644 --- a/app/javascript/mastodon/locales/sr.json +++ b/app/javascript/mastodon/locales/sr.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} обавештења", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Подеси статус приватности", "privacy.direct.long": "Објави само корисницима који су поменути", "privacy.direct.short": "Директно", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Локално", "tabs_bar.notifications": "Обавештења", "tabs_bar.search": "Претрага", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {човек} other {људи}} прича", "ui.beforeunload": "Ако напустите Мастодонт, изгубићете написани нацрт.", "upload_area.title": "Превуците овде да отпремите", diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json index 018fdc85f..90ac623af 100644 --- a/app/javascript/mastodon/locales/sv.json +++ b/app/javascript/mastodon/locales/sv.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} aviseringar", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Justera sekretess", "privacy.direct.long": "Skicka endast till nämnda användare", "privacy.direct.short": "Direkt", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Meddelanden", "tabs_bar.search": "Sök", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, en {person} andra {people}} pratar", "ui.beforeunload": "Ditt utkast kommer att förloras om du lämnar Mastodon.", "upload_area.title": "Dra & släpp för att ladda upp", diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json index 536f65462..21f066439 100644 --- a/app/javascript/mastodon/locales/ta.json +++ b/app/javascript/mastodon/locales/ta.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json index 1c27fab81..806bc9f6f 100644 --- a/app/javascript/mastodon/locales/te.json +++ b/app/javascript/mastodon/locales/te.json @@ -142,9 +142,9 @@ "hashtag.column_header.tag_mode.all": "మరియు {additional}", "hashtag.column_header.tag_mode.any": "లేదా {additional}", "hashtag.column_header.tag_mode.none": "{additional} లేకుండా", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", - "hashtag.column_settings.tag_mode.all": "ఇవన్నీAll of these", + "hashtag.column_settings.select.no_options_message": "ఎటువంటి సూచనలూ దొరకలేదు", + "hashtag.column_settings.select.placeholder": "హ్యాష్ టాగులు నింపండి…", + "hashtag.column_settings.tag_mode.all": "ఇవన్నీ", "hashtag.column_settings.tag_mode.any": "వీటిలో ఏవైనా", "hashtag.column_settings.tag_mode.none": "ఇవేవీ కావు", "hashtag.column_settings.tag_toggle": "Include additional tags in this column", @@ -227,7 +227,7 @@ "navigation_bar.favourites": "ఇష్టపడినవి", "navigation_bar.filters": "మ్యూట్ చేయబడిన పదాలు", "navigation_bar.follow_requests": "అనుసరించడానికి అభ్యర్ధనలు", - "navigation_bar.info": "ఈ దృష్టాంతం గురించి", + "navigation_bar.info": "ఈ సేవిక గురించి", "navigation_bar.keyboard_shortcuts": "హాట్ కీలు", "navigation_bar.lists": "జాబితాలు", "navigation_bar.logout": "లాగ్ అవుట్ చేయండి", @@ -260,6 +260,10 @@ "notifications.filter.follows": "అనుసరిస్తున్నవి", "notifications.filter.mentions": "పేర్కొన్నవి", "notifications.group": "{count} ప్రకటనలు", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "స్టేటస్ గోప్యతను సర్దుబాటు చేయండి", "privacy.direct.long": "పేర్కొన్న వినియోగదారులకు మాత్రమే పోస్ట్ చేయి", "privacy.direct.short": "ప్రత్యక్ష", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "స్థానిక", "tabs_bar.notifications": "ప్రకటనలు", "tabs_bar.search": "శోధన", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} మాట్లాడుతున్నారు", "ui.beforeunload": "మీరు మాస్టొడొన్ను వదిలివేస్తే మీ డ్రాఫ్ట్లు పోతాయి.", "upload_area.title": "అప్లోడ్ చేయడానికి డ్రాగ్ & డ్రాప్ చేయండి", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 5947b04c2..96c1a422b 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -1,20 +1,20 @@ { "account.add_or_remove_from_list": "Add or Remove from lists", - "account.badges.bot": "Bot", - "account.block": "Block @{name}", - "account.block_domain": "Hide everything from {domain}", - "account.blocked": "Blocked", + "account.badges.bot": "บอต", + "account.block": "บล็อค @{name}", + "account.block_domain": "ซ่อนทุกอย่างจาก {domain}", + "account.blocked": "ถูกบล็อค", "account.direct": "Direct Message @{name}", "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", "account.domain_blocked": "Domain hidden", "account.edit_profile": "Edit profile", "account.endorse": "Feature on profile", - "account.follow": "Follow", - "account.followers": "Followers", - "account.followers.empty": "No one follows this user yet.", - "account.follows": "Follows", - "account.follows.empty": "This user doesn't follow anyone yet.", - "account.follows_you": "Follows you", + "account.follow": "ติดตาม", + "account.followers": "ผู้ติดตาม", + "account.followers.empty": "ยังไม่มีใครติดตาม", + "account.follows": "ติดตาม", + "account.follows.empty": "ยังไม่ได้ติดตามใคร", + "account.follows_you": "ติดตามคุณ", "account.hide_reblogs": "Hide boosts from @{name}", "account.link_verified_on": "Ownership of this link was checked on {date}", "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.", @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 76949352f..62bff6cb2 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} notifications", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Gönderi gizliliğini ayarla", "privacy.direct.long": "Sadece bahsedilen kişilere gönder", "privacy.direct.short": "Direkt", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Yerel", "tabs_bar.notifications": "Bildirimler", "tabs_bar.search": "Search", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Upload için sürükle bırak yapınız", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index d6c5317e0..02ecc9689 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} сповіщень", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "Змінити видимість допису", "privacy.direct.long": "Показати тільки згаданим користувачам", "privacy.direct.short": "Направлений", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "Локальна", "tabs_bar.notifications": "Сповіщення", "tabs_bar.search": "Пошук", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Вашу чернетку буде втрачено, якщо ви покинете Mastodon.", "upload_area.title": "Перетягніть сюди, щоб завантажити", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 27effba4c..9941d99d1 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} 条通知", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "设置嘟文可见范围", "privacy.direct.long": "只有被提及的用户能看到", "privacy.direct.short": "私信", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "本站", "tabs_bar.notifications": "通知", "tabs_bar.search": "搜索", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} 人正在讨论", "ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会被丢弃。", "upload_area.title": "将文件拖放到此处开始上传", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 60baffd2e..7e1cf15e6 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} 條通知", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "調整私隱設定", "privacy.direct.long": "只有提及的用戶能看到", "privacy.direct.short": "私人訊息", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "本站", "tabs_bar.notifications": "通知", "tabs_bar.search": "搜尋", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} 位用戶在討論", "ui.beforeunload": "如果你現在離開 Mastodon,你的草稿內容將會被丟棄。", "upload_area.title": "將檔案拖放至此上載", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index c0871d379..c2e807103 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -260,6 +260,10 @@ "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} 條通知", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", "privacy.change": "調整隱私狀態", "privacy.direct.long": "只有被提到的使用者能看到", "privacy.direct.short": "私訊", @@ -342,6 +346,11 @@ "tabs_bar.local_timeline": "本站", "tabs_bar.notifications": "通知", "tabs_bar.search": "搜尋", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} 位使用者在討論", "ui.beforeunload": "如果離開 Mastodon,你的草稿將會不見。", "upload_area.title": "拖放來上傳", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 1622871b8..b45def281 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -29,6 +29,12 @@ import { COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_FAIL, COMPOSE_RESET, + COMPOSE_POLL_ADD, + COMPOSE_POLL_REMOVE, + COMPOSE_POLL_OPTION_ADD, + COMPOSE_POLL_OPTION_CHANGE, + COMPOSE_POLL_OPTION_REMOVE, + COMPOSE_POLL_SETTINGS_CHANGE, } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; @@ -55,6 +61,7 @@ const initialState = ImmutableMap({ is_uploading: false, progress: 0, media_attachments: ImmutableList(), + poll: null, suggestion_token: null, suggestions: ImmutableList(), default_privacy: 'public', @@ -64,6 +71,12 @@ const initialState = ImmutableMap({ tagHistory: ImmutableList(), }); +const initialPoll = ImmutableMap({ + options: ImmutableList(['', '']), + expires_in: 24 * 3600, + multiple: false, +}); + function statusToTextMentions(state, status) { let set = ImmutableOrderedSet([]); @@ -85,6 +98,7 @@ function clearAll(state) { map.set('privacy', state.get('default_privacy')); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); + map.set('poll', null); map.set('idempotencyKey', uuid()); }); }; @@ -247,6 +261,7 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('poll', null); map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: @@ -329,7 +344,27 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); } + + if (action.status.get('poll')) { + map.set('poll', ImmutableMap({ + options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: 24 * 3600, + })); + } }); + case COMPOSE_POLL_ADD: + return state.set('poll', initialPoll); + case COMPOSE_POLL_REMOVE: + return state.set('poll', null); + case COMPOSE_POLL_OPTION_ADD: + return state.updateIn(['poll', 'options'], options => options.push(action.title)); + case COMPOSE_POLL_OPTION_CHANGE: + return state.setIn(['poll', 'options', action.index], action.title); + case COMPOSE_POLL_OPTION_REMOVE: + return state.updateIn(['poll', 'options'], options => options.delete(action.index)); + case COMPOSE_POLL_SETTINGS_CHANGE: + return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); default: return state; } diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js index 955a07754..9564bffcd 100644 --- a/app/javascript/mastodon/reducers/conversations.js +++ b/app/javascript/mastodon/reducers/conversations.js @@ -35,7 +35,7 @@ const updateConversation = (state, item) => state.update('items', list => { } }); -const expandNormalizedConversations = (state, conversations, next) => { +const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => { let items = ImmutableList(conversations.map(conversationToMap)); return state.withMutations(mutable => { @@ -66,7 +66,7 @@ const expandNormalizedConversations = (state, conversations, next) => { }); } - if (!next) { + if (!next && !isLoadingRecent) { mutable.set('hasMore', false); } @@ -81,7 +81,7 @@ export default function conversations(state = initialState, action) { case CONVERSATIONS_FETCH_FAIL: return state.set('isLoading', false); case CONVERSATIONS_FETCH_SUCCESS: - return expandNormalizedConversations(state, action.conversations, action.next); + return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent); case CONVERSATIONS_UPDATE: return updateConversation(state, action.conversation); case CONVERSATIONS_MOUNT: diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 0f0de849f..a7e9c4d0f 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -29,6 +29,7 @@ import listAdder from './list_adder'; import filters from './filters'; import conversations from './conversations'; import suggestions from './suggestions'; +import polls from './polls'; const reducers = { dropdown_menu, @@ -61,6 +62,7 @@ const reducers = { filters, conversations, suggestions, + polls, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/reducers/polls.js b/app/javascript/mastodon/reducers/polls.js new file mode 100644 index 000000000..9956cf83f --- /dev/null +++ b/app/javascript/mastodon/reducers/polls.js @@ -0,0 +1,15 @@ +import { POLLS_IMPORT } from 'mastodon/actions/importer'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); + +const initialState = ImmutableMap(); + +export default function polls(state = initialState, action) { + switch(action.type) { + case POLLS_IMPORT: + return importPolls(state, action.polls); + default: + return state; + } +} diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 1f7ece812..94b570ecd 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -6,6 +6,7 @@ import { TIMELINE_EXPAND_REQUEST, TIMELINE_EXPAND_FAIL, TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, TIMELINE_DISCONNECT, } from '../actions/timelines'; import { @@ -20,6 +21,7 @@ const initialState = ImmutableMap(); const initialTimeline = ImmutableMap({ unread: 0, + online: false, top: true, isLoading: false, hasMore: true, @@ -29,6 +31,8 @@ const initialTimeline = ImmutableMap({ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); + mMap.set('isPartial', isPartial); + if (!next && !isLoadingRecent) mMap.set('hasMore', false); if (!statuses.isEmpty()) { @@ -140,14 +144,13 @@ export default function timelines(state = initialState, action) { return filterTimeline('home', state, action.relationship, action.statuses); case TIMELINE_SCROLL_TOP: return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.update(action.timeline, initialTimeline, map => map.set('online', true)); case TIMELINE_DISCONNECT: return state.update( action.timeline, initialTimeline, - map => map.update( - 'items', - items => items.first() ? items.unshift(null) : items - ) + map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) ); default: return state; diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js index 9928d0dd7..306a068b7 100644 --- a/app/javascript/mastodon/stream.js +++ b/app/javascript/mastodon/stream.js @@ -2,11 +2,11 @@ import WebSocketClient from 'websocket.js'; const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); -export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) { +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { return (dispatch, getState) => { const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); const accessToken = getState().getIn(['meta', 'access_token']); - const { onDisconnect, onReceive } = callbacks(dispatch, getState); + const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); let polling = null; @@ -28,6 +28,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ if (pollingRefresh) { clearPolling(); } + + onConnect(); }, disconnected () { @@ -47,6 +49,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ clearPolling(); pollingRefresh(dispatch); } + + onConnect(); }, }); diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index 4bce74187..6db3bc3dc 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -16,6 +16,7 @@ @import 'mastodon/stream_entries'; @import 'mastodon/boost'; @import 'mastodon/components'; +@import 'mastodon/polls'; @import 'mastodon/introduction'; @import 'mastodon/modal'; @import 'mastodon/emoji_picker'; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 11823a45b..cec59eb1a 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -105,6 +105,10 @@ border-color: lighten($ui-primary-color, 4%); color: lighten($darker-text-color, 4%); } + + &:disabled { + opacity: 0.5; + } } &.button--block { @@ -2336,6 +2340,7 @@ a.account__display-name { .getting-started { color: $dark-text-color; + overflow: auto; &__footer { flex: 0 0 auto; diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss new file mode 100644 index 000000000..4f8c94d83 --- /dev/null +++ b/app/javascript/styles/mastodon/polls.scss @@ -0,0 +1,192 @@ +.poll { + margin-top: 16px; + font-size: 14px; + + li { + margin-bottom: 10px; + position: relative; + height: 18px + 12px; + } + + &__chart { + position: absolute; + top: 0; + left: 0; + height: 100%; + display: inline-block; + border-radius: 4px; + background: darken($ui-primary-color, 14%); + + &.leading { + background: $ui-highlight-color; + } + } + + &__text { + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + input[type=text] { + display: block; + box-sizing: border-box; + flex: 1 1 auto; + width: 20px; + font-size: 14px; + color: $inverted-text-color; + display: block; + outline: 0; + font-family: inherit; + background: $simple-background-color; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + + &:focus { + border-color: $highlight-text-color; + } + } + + &.selectable { + cursor: pointer; + } + + &.editable { + display: flex; + align-items: center; + } + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checkbox { + border-radius: 4px; + } + + &.active { + border-color: $valid-value-color; + background: $valid-value-color; + } + } + + &__number { + display: inline-block; + width: 36px; + font-weight: 700; + padding: 0 10px; + text-align: right; + } + + &__footer { + padding-top: 6px; + padding-bottom: 5px; + color: $dark-text-color; + } + + &__link { + display: inline; + background: transparent; + padding: 0; + margin: 0; + border: 0; + color: $dark-text-color; + text-decoration: underline; + font-size: inherit; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + .button { + height: 36px; + padding: 0 16px; + margin-right: 10px; + font-size: 14px; + } +} + +.compose-form__poll-wrapper { + border-top: 1px solid darken($simple-background-color, 8%); + + ul { + padding: 10px; + } + + .poll__footer { + border-top: 1px solid darken($simple-background-color, 8%); + padding: 10px; + display: flex; + align-items: center; + + button, + select { + flex: 1 1 50%; + } + } + + .button.button-secondary { + font-size: 14px; + font-weight: 400; + padding: 6px 10px; + height: auto; + line-height: inherit; + color: $action-button-color; + border-color: $action-button-color; + margin-right: 5px; + } + + li { + display: flex; + align-items: center; + + .poll__text { + flex: 0 0 auto; + width: calc(100% - (23px + 6px)); + margin-right: 6px; + } + } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } + + .icon-button.disabled { + color: darken($simple-background-color, 14%); + } +} diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 11fa3363a..54b175613 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -4,7 +4,7 @@ class ActivityPub::Activity include JsonLdHelper include Redisable - SUPPORTED_TYPES = %w(Note).freeze + SUPPORTED_TYPES = %w(Note Question).freeze CONVERTED_TYPES = %w(Image Video Article Page).freeze def initialize(json, account, **options) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index d7bd65c80..7e4e57ead 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -6,7 +6,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity RedisLock.acquire(lock_options) do |lock| if lock.acquired? - return if delete_arrived_first?(object_uri) + return if delete_arrived_first?(object_uri) || poll_vote? @status = find_existing_status @@ -40,6 +40,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end resolve_thread(@status) + fetch_replies(@status) distribute(@status) forward_for_reply if @status.public_visibility? || @status.unlisted_visibility? end @@ -67,6 +68,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity thread: replied_to_status, conversation: conversation_from_uri(@object['conversation']), media_attachment_ids: process_attachments.take(4).map(&:id), + owned_poll: process_poll, } end end @@ -159,7 +161,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity return if tag['href'].blank? account = account_from_uri(tag['href']) - account = ::FetchRemoteAccountService.new.call(tag['href'], id: false) if account.nil? + account = ::FetchRemoteAccountService.new.call(tag['href']) if account.nil? return if account.nil? @@ -208,11 +210,55 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachments end + def process_poll + return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array)) + + expires_at = begin + if @object['closed'].is_a?(String) + @object['closed'] + elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass) + Time.now.utc + else + @object['endTime'] + end + end + + if @object['anyOf'].is_a?(Array) + multiple = true + items = @object['anyOf'] + else + multiple = false + items = @object['oneOf'] + end + + @account.polls.new( + multiple: multiple, + expires_at: expires_at, + options: items.map { |item| item['name'].presence || item['content'] }, + cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } + ) + end + + def poll_vote? + return false if replied_to_status.nil? || replied_to_status.poll.nil? || !replied_to_status.local? || !replied_to_status.poll.options.include?(@object['name']) + return true if replied_to_status.poll.expired? + replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id']) + end + def resolve_thread(status) return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri) ThreadResolveWorker.perform_async(status.id, in_reply_to_uri) end + def fetch_replies(status) + collection = @object['replies'] + return if collection.nil? + replies = ActivityPub::FetchRepliesService.new.call(status, collection, false) + return unless replies.nil? + uri = value_or_id(collection) + ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil? + end + def conversation_from_uri(uri) return nil if uri.nil? return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index be3a562d0..892bb9974 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -48,6 +48,12 @@ class ActivityPub::TagManager activity_account_status_url(target.account, target) end + def replies_uri_for(target, page_params = nil) + raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local? + + replies_account_status_url(target.account, target, page_params) + 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 diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 0653214f5..464e1ee7e 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -19,6 +19,10 @@ class Formatter raw_content = status.text + if options[:inline_poll_options] && status.poll + raw_content = raw_content + "\n\n" + status.poll.options.map { |title| "[ ] #{title}" }.join("\n") + end + return '' if raw_content.blank? unless status.local? diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 7a181fb40..9a05d96cf 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -352,7 +352,7 @@ class OStatus::AtomSerializer 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) + append_element(entry, 'content', Formatter.instance.format(status, inline_poll_options: true).to_str || '.', type: 'html', 'xml:lang': status.language) status.active_mentions.sort_by(&:id).each do |mentioned| append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account)) diff --git a/app/models/account.rb b/app/models/account.rb index 0c08c0991..79eecc306 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -245,6 +245,7 @@ class Account < ApplicationRecord def fields_attributes=(attributes) fields = [] old_fields = self[:fields] || [] + old_fields = [] if old_fields.is_a?(Hash) if attributes.is_a?(Hash) attributes.each_value do |attr| @@ -267,6 +268,7 @@ class Account < ApplicationRecord return if fields.size >= MAX_FIELDS tmp = self[:fields] || [] + tmp = [] if tmp.is_a?(Hash) (MAX_FIELDS - tmp.size).times do tmp << { name: '', value: '' } diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 3ab8a0daa..1b22f750c 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -27,6 +27,7 @@ module AccountAssociations # Media has_many :media_attachments, dependent: :destroy + has_many :polls, dependent: :destroy # PuSH subscriptions has_many :subscriptions, dependent: :destroy diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb index b9c800c2a..15eb695cd 100644 --- a/app/models/concerns/status_threading_concern.rb +++ b/app/models/concerns/status_threading_concern.rb @@ -11,6 +11,10 @@ module StatusThreadingConcern find_statuses_from_tree_path(descendant_ids(limit, max_child_id, since_child_id, depth), account, promote: true) end + def self_replies(limit) + account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted]).reorder(id: :asc).limit(limit) + end + private def ancestor_ids(limit) diff --git a/app/models/export.rb b/app/models/export.rb index fc4bb6964..9bf866d35 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -23,7 +23,7 @@ class Export def to_lists_csv CSV.generate do |csv| - account.owned_lists.select(:title).each do |list| + account.owned_lists.select(:title, :id).each do |list| list.accounts.select(:username, :domain).each do |account| csv << [list.title, acct(account)] end diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index b5a10ad2d..d06ae26a8 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -18,11 +18,12 @@ class FeaturedTag < ApplicationRecord delegate :name, to: :tag, allow_nil: true - validates :name, presence: true + validates_associated :tag, on: :create + validates :name, presence: true, on: :create validate :validate_featured_tags_limit, on: :create def name=(str) - self.tag = Tag.find_or_initialize_by(name: str.delete('#').mb_chars.downcase.to_s) + self.tag = Tag.find_or_initialize_by(name: str.strip.delete('#').mb_chars.downcase.to_s) end def increment(timestamp) diff --git a/app/models/poll.rb b/app/models/poll.rb new file mode 100644 index 000000000..09f0b65ec --- /dev/null +++ b/app/models/poll.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: polls +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# status_id :bigint(8) +# expires_at :datetime +# options :string default([]), not null, is an Array +# cached_tallies :bigint(8) default([]), not null, is an Array +# multiple :boolean default(FALSE), not null +# hide_totals :boolean default(FALSE), not null +# votes_count :bigint(8) default(0), not null +# last_fetched_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# lock_version :integer default(0), not null +# + +class Poll < ApplicationRecord + include Expireable + + belongs_to :account + belongs_to :status + + has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :destroy + + validates :options, presence: true + validates :expires_at, presence: true, if: :local? + validates_with PollValidator, on: :create, if: :local? + + scope :attached, -> { where.not(status_id: nil) } + scope :unattached, -> { where(status_id: nil) } + + before_validation :prepare_options + before_validation :prepare_votes_count + + after_initialize :prepare_cached_tallies + + after_commit :reset_parent_cache, on: :update + + def loaded_options + options.map.with_index { |title, key| Option.new(self, key.to_s, title, show_totals_now? ? cached_tallies[key] : nil) } + end + + def possibly_stale? + remote? && last_fetched_before_expiration? && time_passed_since_last_fetch? + end + + def voted?(account) + account.id == account_id || votes.where(account: account).exists? + end + + delegate :local?, to: :account + + def remote? + !local? + end + + class Option < ActiveModelSerializers::Model + attributes :id, :title, :votes_count, :poll + + def initialize(poll, id, title, votes_count) + @poll = poll + @id = id + @title = title + @votes_count = votes_count + end + end + + private + + def prepare_cached_tallies + self.cached_tallies = options.map { 0 } if cached_tallies.empty? + end + + def prepare_votes_count + self.votes_count = cached_tallies.sum unless cached_tallies.empty? + end + + def prepare_options + self.options = options.map(&:strip).reject(&:blank?) + end + + def reset_parent_cache + return if status_id.nil? + Rails.cache.delete("statuses/#{status_id}") + end + + def last_fetched_before_expiration? + last_fetched_at.nil? || expires_at.nil? || last_fetched_at < expires_at + end + + def time_passed_since_last_fetch? + last_fetched_at.nil? || last_fetched_at < 1.minute.ago + end + + def show_totals_now? + expired? || !hide_totals? + end +end diff --git a/app/models/poll_vote.rb b/app/models/poll_vote.rb new file mode 100644 index 000000000..ad24eb691 --- /dev/null +++ b/app/models/poll_vote.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: poll_votes +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# poll_id :bigint(8) +# choice :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# uri :string +# + +class PollVote < ApplicationRecord + belongs_to :account + belongs_to :poll, inverse_of: :votes + + validates :choice, presence: true + validates_with VoteValidator + + after_create_commit :increment_counter_cache + + delegate :local?, to: :account + + def object_type + :vote + end + + private + + def increment_counter_cache + poll.cached_tallies[choice] = (poll.cached_tallies[choice] || 0) + 1 + poll.save + rescue ActiveRecord::StaleObjectError + poll.reload + retry + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 4566c0d20..f576489b4 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -23,6 +23,7 @@ # in_reply_to_account_id :bigint(8) # local_only :boolean # full_status_text :text default(""), not null +# poll_id :bigint(8) # class Status < ApplicationRecord @@ -46,6 +47,7 @@ class Status < ApplicationRecord belongs_to :account, inverse_of: :statuses belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true belongs_to :conversation, optional: true + belongs_to :poll, optional: true belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true @@ -64,12 +66,14 @@ class Status < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy has_one :stream_entry, as: :activity, inverse_of: :status has_one :status_stat, inverse_of: :status + has_one :owned_poll, class_name: 'Poll', inverse_of: :status, dependent: :destroy validates :uri, uniqueness: true, presence: true, unless: :local? validates :text, presence: true, unless: -> { with_media? || reblog? } validates_with StatusLengthValidator validates_with DisallowedHashtagsValidator validates :reblog, uniqueness: { scope: :account }, if: :reblog? + validates_associated :owned_poll default_scope { recent } @@ -106,6 +110,7 @@ class Status < ApplicationRecord :tags, :preview_cards, :stream_entry, + :poll, account: :account_stat, active_mentions: { account: :account_stat }, reblog: [ @@ -116,6 +121,7 @@ class Status < ApplicationRecord :media_attachments, :conversation, :status_stat, + :poll, account: :account_stat, active_mentions: { account: :account_stat }, ], @@ -257,6 +263,8 @@ class Status < ApplicationRecord before_validation :set_conversation before_validation :set_local + after_create :set_poll_id + class << self def selectable_visibilities visibilities.keys - %w(direct limited) @@ -458,6 +466,10 @@ class Status < ApplicationRecord self.reblog = reblog.reblog if reblog? && reblog.reblog? end + def set_poll_id + update_column(:poll_id, owned_poll.id) unless owned_poll.nil? + end + def set_visibility self.visibility = (account.locked? ? :private : :public) if visibility.nil? self.visibility = reblog.visibility if reblog? diff --git a/app/policies/poll_policy.rb b/app/policies/poll_policy.rb new file mode 100644 index 000000000..9d69eb5bb --- /dev/null +++ b/app/policies/poll_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PollPolicy < ApplicationPolicy + def vote? + StatusPolicy.new(current_account, record.status).show? && !current_account.blocking?(record.account) && !record.account.blocking?(current_account) + end +end diff --git a/app/presenters/activitypub/collection_presenter.rb b/app/presenters/activitypub/collection_presenter.rb index ec84ab1a3..28331f0c4 100644 --- a/app/presenters/activitypub/collection_presenter.rb +++ b/app/presenters/activitypub/collection_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class ActivityPub::CollectionPresenter < ActiveModelSerializers::Model - attributes :id, :type, :size, :items, :part_of, :first, :last, :next, :prev + attributes :id, :type, :size, :items, :page, :part_of, :first, :last, :next, :prev end diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index b51e8c544..c001e28aa 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -3,8 +3,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer attributes :id, :type, :actor, :published, :to, :cc - has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :owned_announce? - attribute :proper_uri, key: :object, if: :owned_announce? + has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, if: :serialize_object? + attribute :proper_uri, key: :object, unless: :serialize_object? attribute :atom_uri, if: :announce? def id @@ -43,7 +43,9 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer object.reblog? end - def owned_announce? - announce? && object.account == object.proper.account && object.proper.private_visibility? + def serialize_object? + return true unless announce? + # Serialize private self-boosts of local toots + object.account == object.proper.account && object.proper.private_visibility? && object.local? end end diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb index e8960131b..b03609957 100644 --- a/app/serializers/activitypub/collection_serializer.rb +++ b/app/serializers/activitypub/collection_serializer.rb @@ -7,7 +7,8 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer super end - attributes :id, :type + attribute :id, if: -> { object.id.present? } + attribute :type attribute :total_items, if: -> { object.size.present? } attribute :next, if: -> { object.next.present? } attribute :prev, if: -> { object.prev.present? } @@ -37,6 +38,6 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer end def page? - object.part_of.present? + object.part_of.present? || object.page.present? end end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index c9d23e25f..553f333d8 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -13,12 +13,20 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag + has_one :replies, serializer: ActivityPub::CollectionSerializer, if: :local? + + has_many :poll_options, key: :one_of, if: :poll_and_not_multiple? + has_many :poll_options, key: :any_of, if: :poll_and_multiple? + + attribute :end_time, if: :poll_and_expires? + attribute :closed, if: :poll_and_expired? + def id ActivityPub::TagManager.instance.uri_for(object) end def type - 'Note' + object.poll ? 'Question' : 'Note' end def summary @@ -33,6 +41,22 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer { object.language => Formatter.instance.format(object) } end + def replies + replies = object.self_replies(5).pluck(:id, :uri) + last_id = replies.last&.first + + ActivityPub::CollectionPresenter.new( + type: :unordered, + id: ActivityPub::TagManager.instance.replies_uri_for(object), + first: ActivityPub::CollectionPresenter.new( + type: :unordered, + part_of: ActivityPub::TagManager.instance.replies_uri_for(object), + items: replies.map(&:second), + next: last_id ? ActivityPub::TagManager.instance.replies_uri_for(object, page: true, min_id: last_id) : nil + ) + ) + end + def language? object.language.present? end @@ -97,6 +121,32 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer object.account.local? end + def poll_options + object.poll.loaded_options + end + + def poll_and_multiple? + object.poll&.multiple? + end + + def poll_and_not_multiple? + object.poll && !object.poll.multiple? + end + + def closed + object.poll.expires_at.iso8601 + end + + alias end_time closed + + def poll_and_expires? + object.poll&.expires_at&.present? + end + + def poll_and_expired? + object.poll&.expired? + end + class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper @@ -164,4 +214,34 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer class CustomEmojiSerializer < ActivityPub::EmojiSerializer end + + class OptionSerializer < ActiveModel::Serializer + class RepliesSerializer < ActiveModel::Serializer + attributes :type, :total_items + + def type + 'Collection' + end + + def total_items + object.votes_count + end + end + + attributes :type, :name + + has_one :replies, serializer: ActivityPub::NoteSerializer::OptionSerializer::RepliesSerializer + + def type + 'Note' + end + + def name + object.title + end + + def replies + object + end + end end diff --git a/app/serializers/activitypub/vote_serializer.rb b/app/serializers/activitypub/vote_serializer.rb new file mode 100644 index 000000000..248190404 --- /dev/null +++ b/app/serializers/activitypub/vote_serializer.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ActivityPub::VoteSerializer < ActiveModel::Serializer + class NoteSerializer < ActiveModel::Serializer + attributes :id, :type, :name, :attributed_to, + :in_reply_to, :to + + def id + ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#votes/', object.id].join + end + + def type + 'Note' + end + + def name + object.poll.options[object.choice.to_i] + end + + def attributed_to + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def in_reply_to + ActivityPub::TagManager.instance.uri_for(object.poll.status) + end + + def to + ActivityPub::TagManager.instance.uri_for(object.poll.account) + end + end + + attributes :id, :type, :actor, :to + + has_one :object, serializer: ActivityPub::VoteSerializer::NoteSerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#votes/', object.id, '/activity'].join + end + + def type + 'Create' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def to + ActivityPub::TagManager.instance.uri_for(object.poll.account) + end +end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 19817e89e..cfb25c120 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -3,7 +3,7 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, :media_attachments, :settings, - :max_toot_chars + :max_toot_chars, :poll_limits has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer @@ -11,6 +11,15 @@ class InitialStateSerializer < ActiveModel::Serializer StatusLengthValidator::MAX_CHARS end + def poll_limits + { + max_options: PollValidator::MAX_OPTIONS, + max_option_chars: PollValidator::MAX_OPTION_CHARS, + min_expiration: PollValidator::MIN_EXPIRATION, + max_expiration: PollValidator::MAX_EXPIRATION, + } + end + def meta store = { streaming_api_base_url: Rails.configuration.x.streaming_api_base_url, diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 41ed1995d..30e8dcbc1 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -4,7 +4,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer include RoutingHelper attributes :uri, :title, :description, :email, - :version, :urls, :stats, :thumbnail, :max_toot_chars, + :version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits, :languages, :registrations has_one :contact_account, serializer: REST::AccountSerializer @@ -39,6 +39,15 @@ class REST::InstanceSerializer < ActiveModel::Serializer StatusLengthValidator::MAX_CHARS end + def poll_limits + { + max_options: PollValidator::MAX_OPTIONS, + max_option_chars: PollValidator::MAX_OPTION_CHARS, + min_expiration: PollValidator::MIN_EXPIRATION, + max_expiration: PollValidator::MAX_EXPIRATION, + } + end + def stats { user_count: instance_presenter.user_count, diff --git a/app/serializers/rest/poll_serializer.rb b/app/serializers/rest/poll_serializer.rb new file mode 100644 index 000000000..4dae1c09f --- /dev/null +++ b/app/serializers/rest/poll_serializer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class REST::PollSerializer < ActiveModel::Serializer + attributes :id, :expires_at, :expired, + :multiple, :votes_count + + has_many :loaded_options, key: :options + + attribute :voted, if: :current_user? + + def id + object.id.to_s + end + + def expired + object.expired? + end + + def voted + object.voted?(current_user.account) + end + + def current_user? + !current_user.nil? + end + + class OptionSerializer < ActiveModel::Serializer + attributes :title, :votes_count + end +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index b72eebb10..7185121d6 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -23,6 +23,7 @@ class REST::StatusSerializer < ActiveModel::Serializer has_many :emojis, serializer: REST::CustomEmojiSerializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer + has_one :poll, serializer: REST::PollSerializer def id object.id.to_s diff --git a/app/serializers/rss/account_serializer.rb b/app/serializers/rss/account_serializer.rb index bde360a41..712b1347a 100644 --- a/app/serializers/rss/account_serializer.rb +++ b/app/serializers/rss/account_serializer.rb @@ -22,7 +22,7 @@ class RSS::AccountSerializer item.title(status.title) .link(TagManager.instance.url_for(status)) .pub_date(status.created_at) - .description(status.spoiler_text.presence || Formatter.instance.format(status).to_str) + .description(status.spoiler_text.presence || Formatter.instance.format(status, inline_poll_options: true).to_str) status.media_attachments.each do |media| item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, length: media.file.size) diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb new file mode 100644 index 000000000..4f9814fcd --- /dev/null +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemotePollService < BaseService + include JsonLdHelper + + def call(poll, on_behalf_of = nil) + @json = fetch_resource(poll.status.uri, true, on_behalf_of) + + return unless supported_context? && expected_type? + + expires_at = begin + if @json['closed'].is_a?(String) + @json['closed'] + elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) + Time.now.utc + else + @json['endTime'] + end + end + + items = begin + if @json['anyOf'].is_a?(Array) + @json['anyOf'] + else + @json['oneOf'] + end + end + + latest_options = items.map { |item| item['name'].presence || item['content'] } + + # If for some reasons the options were changed, it invalidates all previous + # votes, so we need to remove them + poll.votes.delete_all if latest_options != poll.options + + begin + poll.update!( + last_fetched_at: Time.now.utc, + expires_at: expires_at, + options: latest_options, + cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } + ) + rescue ActiveRecord::StaleObjectError + poll.reload + retry + end + end + + private + + def supported_context? + super(@json) + end + + def expected_type? + equals_or_includes_any?(@json['type'], %w(Question)) + end +end diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb new file mode 100644 index 000000000..569d0d7c1 --- /dev/null +++ b/app/services/activitypub/fetch_replies_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRepliesService < BaseService + include JsonLdHelper + + def call(parent_status, collection_or_uri, allow_synchronous_requests = true) + @account = parent_status.account + @allow_synchronous_requests = allow_synchronous_requests + + @items = collection_items(collection_or_uri) + return if @items.nil? + + FetchReplyWorker.push_bulk(filtered_replies) + + @items + end + + private + + def collection_items(collection_or_uri) + collection = fetch_collection(collection_or_uri) + return unless collection.is_a?(Hash) + + collection = fetch_collection(collection['first']) if collection['first'].present? + return unless collection.is_a?(Hash) + + case collection['type'] + when 'Collection', 'CollectionPage' + collection['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + collection['orderedItems'] + end + end + + def fetch_collection(collection_or_uri) + return collection_or_uri if collection_or_uri.is_a?(Hash) + return unless @allow_synchronous_requests + return if invalid_origin?(collection_or_uri) + fetch_resource_without_id_validation(collection_or_uri, nil, true) + end + + def filtered_replies + # Only fetch replies to the same server as the original status to avoid + # amplification attacks. + + # Also limit to 5 fetched replies to limit potential for DoS. + @items.map { |item| value_or_id(item) }.reject { |uri| invalid_origin?(uri) }.take(5) + end + + def invalid_origin?(url) + return true if unsupported_uri_scheme?(url) + + needle = Addressable::URI.parse(url).host + haystack = Addressable::URI.parse(@account.uri).host + + !haystack.casecmp(needle).zero? + end +end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index cfb266fbb..8a9d26c56 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -15,6 +15,7 @@ class PostStatusService < BaseService # @option [String] :spoiler_text # @option [String] :language # @option [String] :scheduled_at + # @option [Hash] :poll Optional poll to attach # @option [Enumerable] :media_ids Optional array of media IDs to attach # @option [Doorkeeper::Application] :application # @option [String] :idempotency Optional idempotency key @@ -28,6 +29,7 @@ class PostStatusService < BaseService return idempotency_duplicate if idempotency_given? && idempotency_duplicate? validate_media! + validate_poll! preprocess_attributes! if scheduled? @@ -98,13 +100,19 @@ class PostStatusService < BaseService def validate_media! return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable) - raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 + raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present? @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)) raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:video?) end + def validate_poll! + return if @options[:poll].blank? + + @poll = @account.polls.new(@options[:poll]) + end + def language_from_option(str) ISO_639.find(str)&.alpha2 end @@ -157,6 +165,7 @@ class PostStatusService < BaseService text: @text, media_attachments: @media || [], thread: @in_reply_to, + owned_poll: @poll, sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?, spoiler_text: @options[:spoiler_text] || '', visibility: @visibility, diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index ed0c56923..b98759bf6 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -20,7 +20,7 @@ class ResolveURLService < BaseService def process_url if equals_or_includes_any?(type, %w(Application Group Organization Person Service)) FetchRemoteAccountService.new.call(atom_url, body, protocol) - elsif equals_or_includes_any?(type, %w(Note Article Image Video Page)) + elsif equals_or_includes_any?(type, %w(Note Article Image Video Page Question)) FetchRemoteStatusService.new.call(atom_url, body, protocol) end end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index fc3bc03a5..24fa1be69 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -41,6 +41,7 @@ class SuspendAccountService < BaseService @account = account @options = options + reject_follows! purge_user! purge_profile! purge_content! @@ -48,6 +49,14 @@ class SuspendAccountService < BaseService private + def reject_follows! + return if @account.local? || !@account.activitypub? + + ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow| + [build_reject_json(follow), follow.target_account_id, follow.account.inbox_url] + end + end + def purge_user! return if !@account.local? || @account.user.nil? @@ -84,7 +93,7 @@ class SuspendAccountService < BaseService @account.locked = false @account.display_name = '' @account.note = '' - @account.fields = {} + @account.fields = [] @account.statuses_count = 0 @account.followers_count = 0 @account.following_count = 0 @@ -120,6 +129,14 @@ class SuspendAccountService < BaseService @delete_actor_json = Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account)) end + def build_reject_json(follow) + ActiveModelSerializers::SerializableResource.new( + follow, + serializer: ActivityPub::RejectFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def delivery_inboxes @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url) end diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb new file mode 100644 index 000000000..5b80da03a --- /dev/null +++ b/app/services/vote_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class VoteService < BaseService + include Authorization + + def call(account, poll, choices) + authorize_with account, poll, :vote? + + @account = account + @poll = poll + @choices = choices + @votes = [] + + return if @poll.expired? + + ApplicationRecord.transaction do + @choices.each do |choice| + @votes << @poll.votes.create!(account: @account, choice: choice) + end + end + + return if @poll.account.local? + + @votes.each do |vote| + ActivityPub::DeliveryWorker.perform_async( + build_json(vote), + @account.id, + @poll.account.inbox_url + ) + end + end + + private + + def build_json(vote) + ActiveModelSerializers::SerializableResource.new( + vote, + serializer: ActivityPub::VoteSerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end diff --git a/app/validators/poll_validator.rb b/app/validators/poll_validator.rb new file mode 100644 index 000000000..fd497c8d0 --- /dev/null +++ b/app/validators/poll_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PollValidator < ActiveModel::Validator + MAX_OPTIONS = 4 + MAX_OPTION_CHARS = 25 + MAX_EXPIRATION = 1.month.freeze + MIN_EXPIRATION = 5.minutes.freeze + + def validate(poll) + current_time = Time.now.utc + + poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1 + poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS + poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS } + poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time > MAX_EXPIRATION + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && poll.expires_at - current_time < MIN_EXPIRATION + end +end diff --git a/app/validators/vote_validator.rb b/app/validators/vote_validator.rb new file mode 100644 index 000000000..2e1818bdb --- /dev/null +++ b/app/validators/vote_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class VoteValidator < ActiveModel::Validator + def validate(vote) + vote.errors.add(:base, I18n.t('polls.errors.expired')) if vote.poll.expired? + + if vote.poll.multiple? && vote.poll.votes.where(account: vote.account, choice: vote.choice).exists? + vote.errors.add(:base, I18n.t('polls.errors.already_voted')) + elsif !vote.poll.multiple? && vote.poll.votes.where(account: vote.account).exists? + vote.errors.add(:base, I18n.t('polls.errors.already_voted')) + end + end +end diff --git a/app/views/about/_forms.html.haml b/app/views/about/_forms.html.haml index 81f7173f7..78a422690 100644 --- a/app/views/about/_forms.html.haml +++ b/app/views/about/_forms.html.haml @@ -1,7 +1,7 @@ - if @instance_presenter.open_registrations = render 'registration' - else - = link_to t('auth.register_elsewhere'), 'https://joinmastodon.org', class: 'button button-primary' + = link_to t('auth.register_elsewhere'), 'https://joinmastodon.org/#getting-started', class: 'button button-primary' .closed-registrations-message - if @instance_presenter.closed_registrations_message.blank? diff --git a/app/views/about/_links.html.haml b/app/views/about/_links.html.haml index f79c37e65..381f301f9 100644 --- a/app/views/about/_links.html.haml +++ b/app/views/about/_links.html.haml @@ -11,6 +11,6 @@ = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' %li= link_to t('about.about_this'), about_more_path %li - = link_to 'https://joinmastodon.org/' do + = link_to 'https://joinmastodon.org/#getting-started' do = "#{t('about.other_instances')}" %i.fa.fa-external-link{ style: 'padding-left: 5px;' } diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index e123d657f..b19d2452a 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -22,7 +22,10 @@ %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) - - if !status.media_attachments.empty? + - if status.poll + = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do + = render partial: 'stream_entries/poll', locals: { poll: status.poll } + - elsif !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do diff --git a/app/views/stream_entries/_poll.html.haml b/app/views/stream_entries/_poll.html.haml new file mode 100644 index 000000000..d6b2c0cd9 --- /dev/null +++ b/app/views/stream_entries/_poll.html.haml @@ -0,0 +1,27 @@ +- show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired? + +.poll + %ul + - poll.loaded_options.each do |option| + %li + - if show_results + - percent = poll.votes_count > 0 ? 100 * option.votes_count / poll.votes_count : 0 + %span.poll__chart{ style: "width: #{percent}%" } + + %label.poll__text>< + %span.poll__number= percent.round + = option.title + - else + %label.poll__text>< + %span.poll__input{ class: poll.multiple? ? 'checkbox' : nil}>< + = option.title + .poll__footer + - unless show_results + %button.button.button-secondary{ disabled: true } + = t('statuses.poll.vote') + + %span= t('statuses.poll.total_votes', count: poll.votes_count) + + - unless poll.expires_at.nil? + · + %span= l poll.expires_at diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 28b4e3217..d3441ca90 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -27,7 +27,10 @@ .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) - - if !status.media_attachments.empty? + - if status.poll + = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do + = render partial: 'stream_entries/poll', locals: { poll: status.poll } + - elsif !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do diff --git a/app/workers/activitypub/fetch_replies_worker.rb b/app/workers/activitypub/fetch_replies_worker.rb new file mode 100644 index 000000000..54d98f228 --- /dev/null +++ b/app/workers/activitypub/fetch_replies_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRepliesWorker + include Sidekiq::Worker + include ExponentialBackoff + + sidekiq_options queue: 'pull', retry: 3 + + def perform(parent_status_id, replies_uri) + ActivityPub::FetchRepliesService.new.call(Status.find(parent_status_id), replies_uri) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/concerns/exponential_backoff.rb b/app/workers/concerns/exponential_backoff.rb new file mode 100644 index 000000000..f2b931e33 --- /dev/null +++ b/app/workers/concerns/exponential_backoff.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ExponentialBackoff + extend ActiveSupport::Concern + + included do + sidekiq_retry_in do |count| + 15 + 10 * (count**4) + rand(10 * (count**4)) + end + end +end diff --git a/app/workers/fetch_reply_worker.rb b/app/workers/fetch_reply_worker.rb new file mode 100644 index 000000000..f7aa25e81 --- /dev/null +++ b/app/workers/fetch_reply_worker.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class FetchReplyWorker + include Sidekiq::Worker + include ExponentialBackoff + + sidekiq_options queue: 'pull', retry: 3 + + def perform(child_url) + FetchRemoteStatusService.new.call(child_url) + end +end diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index c18a778d5..8bba9ca75 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -2,13 +2,10 @@ class ThreadResolveWorker include Sidekiq::Worker + include ExponentialBackoff sidekiq_options queue: 'pull', retry: 3 - sidekiq_retry_in do |count| - 15 + 10 * (count**4) + rand(10 * (count**4)) - end - def perform(child_status_id, parent_url) child_status = Status.find(child_status_id) parent_status = FetchRemoteStatusService.new.call(parent_url) diff --git a/config/database.yml b/config/database.yml index 82e560515..c10bff6b2 100644 --- a/config/database.yml +++ b/config/database.yml @@ -3,6 +3,7 @@ default: &default pool: <%= ENV["DB_POOL"] || ENV['MAX_THREADS'] || 5 %> timeout: 5000 encoding: unicode + sslmode: <%= ENV['DB_SSLMODE'] || "prefer" %> development: <<: *default @@ -31,3 +32,4 @@ production: host: <%= ENV['DB_HOST'] || 'localhost' %> port: <%= ENV['DB_PORT'] || 5432 %> prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %> + diff --git a/config/locales/ar.yml b/config/locales/ar.yml index ec8b15cba..67fa97c59 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -319,6 +319,8 @@ ar: back_to_account: العودة إلى الحساب title: "%{acct} مُتابِعون" instances: + by_domain: النطاق + delivery_available: التسليم متوفر known_accounts: few: "%{count} حسابات معروفة" many: "%{count} حسابات معروفة" @@ -598,10 +600,13 @@ ar: size: الحجم blocks: قمت بحظر csv: CSV + domain_blocks: النطاقات المحظورة follows: أنت تتبع lists: القوائم mutes: قُمتَ بكتم storage: ذاكرة التخزين + featured_tags: + add_new: إضافة واحد filters: contexts: home: الخيط الزمني الرئيسي @@ -643,10 +648,16 @@ ar: two: هناك شيء ما ليس على ما يرام! يُرجى مراجعة الأخطاء الـ %{count} أدناه zero: هناك شيء ما ليس على ما يرام! يُرجى مراجعة الأخطاء الـ %{count} أدناه imports: + modes: + merge: دمج + merge_long: الإبقاء علي التسجيلات الحالية وإضافة الجديدة + overwrite: إعادة الكتابة + overwrite_long: استبدال التسجيلات الحالية بالجديدة preface: بإمكانك استيراد بيانات قد قُمتَ بتصديرها مِن مثيل خادوم آخَر، كقوائم المستخدِمين الذين كنتَ تتابِعهم أو قُمتَ بحظرهم. success: تم تحميل بياناتك بنجاح وسيتم معالجتها في الوقت المناسب types: blocking: قائمة المحظورين + domain_blocking: قائمة النطاقات المحظورة following: قائمة المستخدمين المتبوعين muting: قائمة الكتم upload: تحميل @@ -761,6 +772,16 @@ ar: no_account_html: أليس عندك حساب بعدُ ؟ يُمْكنك <a href='%{sign_up_path}' target='_blank'>التسجيل مِن هنا</a> proceed: أكمل المتابعة prompt: 'إنك بصدد متابعة :' + remote_interaction: + favourite: + proceed: المواصلة إلى المفضلة + prompt: 'ترغب في إضافة هذا التبويق إلى مفضلتك:' + reblog: + proceed: المواصلة إلى الترقية + prompt: 'ترغب في ترقية هذا التبويق:' + reply: + proceed: المواصلة إلى الرد + prompt: 'ترغب في الرد على هذا التبويق:' remote_unfollow: error: خطأ title: العنوان @@ -903,6 +924,8 @@ ar: review_server_policies: مراجعة شروط السيرفر subject: disable: تم تجميد حسابك %{acct} + none: تحذير إلى %{acct} + suspend: لقد تم تعليق حسابك %{acct} title: disable: الحساب مُجمَّد none: تحذير diff --git a/config/locales/bn.yml b/config/locales/bn.yml new file mode 100644 index 000000000..560e74398 --- /dev/null +++ b/config/locales/bn.yml @@ -0,0 +1,22 @@ +--- +bn: + about: + about_hashtag_html: এগুলো প্রকাশ্য লেখা যার হ্যাশট্যাগ <strong>#%{hashtag}</strong>। আপনি এগুলোর ব্যবহার বা সাথে যুক্ত হতে পারবেন যদি আপনার যুক্তবিশ্বের কোথাও নিবন্ধন থেকে থাকে। + about_mastodon_html: মাস্টাডন উন্মুক্ত ইন্টারনেটজালের নিয়ম এবং স্বাধীন ও মুক্ত উৎসের সফটওয়্যারের ভিত্তিতে তৈরী একটি সামাজিক যোগাযোগ মাধ্যম। এটি ইমেইলের মত বিকেন্দ্রীভূত। + about_this: কি + administered_by: 'পরিচালনা করছেন:' + api: সফটওয়্যার তৈরীর নিয়ম (API) + apps: মোবাইল অ্যাপ + closed_registrations: এই সার্ভারে এখন নিবন্ধন বন্ধ। কিন্তু ! অন্য একটি সার্ভার খুঁজে নিবন্ধন করলেও একই নেটওয়ার্কে ঢুকতে পারবেন। + contact: যোগাযোগ + contact_missing: নেই + contact_unavailable: প্রযোজ্য নয় + documentation: ব্যবহারবিলি + extended_description_html: | + <h3>নিয়মের জন্য উপযুক্ত জায়গা</h3> + <p>বিস্তারিত বিবরণ এখনো যুক্ত করা হয়নি</p> + features: + humane_approach_body: অনন্যা নেটওয়ার্কের ব্যর্থতা থেকে শিখে, মাস্টাডনের লক্ষ্য নৈতিক পরিকল্পনার দ্বারা সামাজিক মাধ্যমের অপব্যবহারের বিরোধিতা করা। + humane_approach_title: একটি মনুষ্যত্বপূর্ণ চেষ্টা + not_a_product_body: মাস্টাডন কোনো ব্যবসায়িক নেটওয়ার্ক না। কোনো বিজ্ঞাপন নেই, কোনো তথ্য খনি নেই, কোনো বাধার দেয়াল নেই। এর কোনো কেন্দ্রীয় কর্তৃপক্ষ নেই। + not_a_product_title: আপনি একজন মানুষ, পণ্য নন diff --git a/config/locales/co.yml b/config/locales/co.yml index 8955f7a68..8fcb27598 100644 --- a/config/locales/co.yml +++ b/config/locales/co.yml @@ -302,6 +302,7 @@ co: back_to_account: Rivene à u Contu title: Abbunati à %{acct} instances: + by_domain: Duminiu delivery_available: Rimessa dispunibule known_accounts: one: "%{count} contu cunnisciutu" @@ -733,6 +734,16 @@ co: older: Più vechju prev: Nanzu truncate: "…" + polls: + errors: + already_voted: Avete digià vutatu nant'à stu scandagliu + duplicate_options: cuntene uzzione doppie + duration_too_long: hè troppu luntanu indè u futuru + duration_too_short: hè troppu prossimu + expired: U scandagliu hè digià finitu + over_character_limit: ùn ponu micca esse più longhi chè %{max} caratteri + too_few_options: deve avè più d'un'uzzione + too_many_options: ùn pò micca avè più di %{max} uzzione preferences: languages: Lingue other: Altre @@ -842,6 +853,11 @@ co: ownership: Pudete puntarulà solu unu di i vostri propii statuti private: Ùn pudete micca puntarulà un statutu ch’ùn hè micca pubblicu reblog: Ùn pudete micca puntarulà una spartera + poll: + total_votes: + one: "%{count} votu" + other: "%{count} voti" + vote: Vutà show_more: Vede di più sign_in_to_participate: Cunnettatevi per participà à a cunversazione title: '%{name}: "%{quote}"' diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 7a4c3f255..fe83bd57a 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -31,9 +31,9 @@ cs: privacy_policy: Zásady soukromí source_code: Zdrojový kód status_count_after: - few: příspěvky - one: příspěvek - other: příspěvků + few: tooty + one: toot + other: tootů status_count_before: Kteří napsali terms: Podmínky používání user_count_after: @@ -150,7 +150,7 @@ cs: already_confirmed: Tento uživatel je již potvrzen send: Znovu odeslat potvrzovací e-mail success: Potvrzovací e-mail byl úspěšně odeslán! - reset: Resetovat + reset: Obnovit reset_password: Obnovit heslo resubscribe: Znovu odebírat role: Oprávnění @@ -167,7 +167,7 @@ cs: targeted_reports: Nahlášeni ostatními silence: Utišit silenced: Utišen/a - statuses: Příspěvky + statuses: Tooty subscribe: Odebírat suspended: Pozastaven/a title: Účty @@ -191,7 +191,7 @@ cs: destroy_custom_emoji: "%{name} zničil/a emoji %{target}" destroy_domain_block: "%{name} odblokoval/a doménu %{target}" destroy_email_domain_block: "%{name} odebral/a e-mailovou doménu %{target} z černé listiny" - destroy_status: "%{name} odstranil/a příspěvek uživatele %{target}" + destroy_status: "%{name} odstranil/a toot uživatele %{target}" disable_2fa_user: "%{name} vypnul/a požadavek pro dvoufaktorovou autentikaci pro uživatele %{target}" disable_custom_emoji: "%{name} zakázal/a emoji %{target}" disable_user: "%{name} zakázal/a přihlašování pro uživatele %{target}" @@ -201,7 +201,7 @@ cs: promote_user: "%{name} povýšil/a uživatele %{target}" remove_avatar_user: "%{name} odstranil/a avatar uživatele %{target}" reopen_report: "%{name} znovuotevřel/a nahlášení %{target}" - reset_password_user: "%{name} resetoval/a heslo uživatele %{target}" + reset_password_user: "%{name} obnovil/a heslo uživatele %{target}" resolve_report: "%{name} vyřešil/a nahlášení %{target}" silence_account: "%{name} utišil/a účet uživatele %{target}" suspend_account: "%{name} pozastavil/a účet uživatele %{target}" @@ -209,8 +209,8 @@ cs: unsilence_account: "%{name} odtišil/a účet uživatele %{target}" unsuspend_account: "%{name} zrušil/a pozastavení účtu uživatele %{target}" update_custom_emoji: "%{name} aktualizoval/a emoji %{target}" - update_status: "%{name} aktualizoval/a příspěvek uživatele %{target}" - deleted_status: "(smazaný příspěvek)" + update_status: "%{name} aktualizoval/a toot uživatele %{target}" + deleted_status: "(smazaný toot)" title: Záznam auditu custom_emojis: by_domain: Doména @@ -307,6 +307,7 @@ cs: back_to_account: Zpět na účet title: Sledující uživatele %{acct} instances: + by_domain: Doména delivery_available: Doručení je k dispozici known_accounts: few: "%{count} známé účty" @@ -380,7 +381,7 @@ cs: updated_at: Aktualizováno settings: activity_api_enabled: - desc_html: Počty lokálně publikovaných příspěvků, aktivních uživatelů a nových registrací, v týdenních intervalech + desc_html: Počty lokálně publikovaných tootů, aktivních uživatelů a nových registrací, v týdenních intervalech title: Publikovat hromadné statistiky o uživatelské aktivitě bootstrap_timeline_accounts: desc_html: Je-li uživatelskch jmen více, oddělujte je čárkami. Lze zadat pouze místní a odemknuté účty. Je-li tohle prázdné, jsou výchozí hodnotou všichni místní administrátoři. @@ -455,8 +456,8 @@ cs: media: title: Média no_media: Žádná média - no_status_selected: Nebyly změněny žádné příspěvky, neboť žádné nebyly vybrány - title: Příspěvky účtu + no_status_selected: Nebyly změněny žádné tooty, neboť žádné nebyly vybrány + title: Tooty účtu with_media: S médii subscriptions: callback_url: Zpáteční URL @@ -491,7 +492,7 @@ cs: settings: 'Změnit volby e-mailu: %{link}' view: 'Zobrazit:' view_profile: Zobrazit profil - view_status: Zobrazit příspěvek + view_status: Zobrazit toot applications: created: Aplikace úspěšně vytvořena destroyed: Aplikace úspěšně smazána @@ -508,7 +509,7 @@ cs: delete_account_html: Chcete-li odstranit svůj účet, <a href="%{path}">pokračujte zde</a>. Budete požádán/a o potvrzení. didnt_get_confirmation: Neobdržel/a jste pokyny pro potvrzení? forgot_password: Zapomněl/a jste heslo? - invalid_reset_password_token: Token na obnovu hesla je buď neplatný, nebo vypršel. Prosím vyžádejte si nový. + invalid_reset_password_token: Token pro obnovení hesla je buď neplatný, nebo vypršel. Prosím vyžádejte si nový. login: Přihlásit logout: Odhlásit migrate_account: Přesunout se na jiný účet @@ -598,7 +599,7 @@ cs: featured_tags: add_new: Přidat nový errors: - limit: Již jste nastavil/a maximální počet oblíbených hashtagů + limit: Již jste zvýraznil/a maximální počet hashtagů filters: contexts: home: Domovská časová osa @@ -617,16 +618,16 @@ cs: title: Přidat nový filtr followers: domain: Doména - explanation_html: Chcete-li zaručit soukromí vašich příspěvků, musíte mít na vědomí, kdo vás sleduje. <strong>Vaše soukromé příspěvky jsou doručeny na všechny servery, kde máte sledující</strong>. Nejspíš si je budete chtít zkontrolovat a odstranit sledující na serverech, jejichž provozovatelům či softwaru nedůvěřujete s respektováním vašeho soukromí. + explanation_html: Chcete-li zaručit soukromí vašich tootů, musíte mít na vědomí, kdo vás sleduje. <strong>Vaše soukromé tooty jsou doručeny na všechny servery, kde máte sledující</strong>. Nejspíš si je budete chtít zkontrolovat a odstranit sledující na serverech, jejichž provozovatelům či softwaru nedůvěřujete s respektováním vašeho soukromí. followers_count: Počet sledujících - lock_link: Zamkněte svůj účet + lock_link: Uzamkněte svůj účet purge: Odstranit ze sledujících success: few: V průběhu blokování sledujících ze %{count} domén... one: V průběhu blokování sledujících z jedné domény... other: V průběhu blokování sledujících z %{count} domén... true_privacy_html: Berte prosím na vědomí, že <strong>skutečného soukromí se dá dosáhnout pouze za pomoci end-to-end šifrování</strong>. - unlocked_warning_html: Kdokoliv vás může sledovat a okamžitě vidět vaše soukromé příspěvky. %{lock_link}, abyste mohl/a kontrolovat a odmítat sledující. + unlocked_warning_html: Kdokoliv vás může sledovat a okamžitě vidět vaše soukromé tooty. %{lock_link}, abyste mohl/a kontrolovat a odmítat sledující. unlocked_warning_title: Váš účet není uzamčen footer: developers: Vývojáři @@ -683,7 +684,7 @@ cs: limit: Dosáhl/a jste maximálního počtu seznamů media_attachments: validations: - images_and_video: K příspěvku, který již obsahuje obrázky, nelze připojit video + images_and_video: K tootu, který již obsahuje obrázky, nelze připojit video too_many: Nelze připojit více než 4 soubory migrations: acct: přezdívka@doména nového účtu @@ -707,8 +708,8 @@ cs: other: "%{count} nových oznámení od vaší poslední návštěvy \U0001F418" title: Ve vaší nepřítomnosti... favourite: - body: 'Váš příspěvek si oblíbil/a %{name}:' - subject: "%{name} si oblíbil/a váš příspěvek" + body: 'Váš toot si oblíbil/a %{name}:' + subject: "%{name} si oblíbil/a váš toot" title: Nové oblíbení follow: body: "%{name} vás nyní sleduje!" @@ -725,9 +726,9 @@ cs: subject: Byl/a jste zmíněn/a uživatelem %{name} title: Nová zmínka reblog: - body: 'Váš příspěvek byl boostnutý uživatelem %{name}:' - subject: "%{name} boostnul/a váš příspěvek" - title: Nové boostnutí + body: 'Váš toot byl boostnutý uživatelem %{name}:' + subject: "%{name} boostnul/a váš toot" + title: Nový boost number: human: decimal_units: @@ -744,6 +745,16 @@ cs: older: Starší prev: Před truncate: "…" + polls: + errors: + already_voted: V této anketě jste již hlasoval/a + duplicate_options: obsahuje duplicitní položky + duration_too_long: je příliš daleko v budoucnosti + duration_too_short: je příliš brzy + expired: Anketa již skončila + over_character_limit: nesmí být každá delší než %{max} znaků + too_few_options: musí mít více než jednu položku + too_many_options: nesmí obsahovat více než %{max} položky preferences: languages: Jazyky other: Ostatní @@ -822,7 +833,7 @@ cs: development: Vývoj edit_profile: Upravit profil export: Export dat - featured_tags: Oblíbené hashtagy + featured_tags: Zvýrazněné hashtagy followers: Autorizovaní sledující import: Import migrate: Přesunutí účtu @@ -856,6 +867,12 @@ cs: ownership: Nelze připnout toot někoho jiného private: Nelze připnout neveřejné tooty reblog: Nelze připnout boostnutí + poll: + total_votes: + few: "%{count} hlasy" + one: "%{count} hlas" + other: "%{count} hlasů" + vote: Hlasovat show_more: Zobrazit více sign_in_to_participate: Chcete-li se účastnit této konverzace, přihlaste se title: "%{name}: „%{quote}“" diff --git a/config/locales/devise.cs.yml b/config/locales/devise.cs.yml index 83534cccd..b87c7472c 100644 --- a/config/locales/devise.cs.yml +++ b/config/locales/devise.cs.yml @@ -3,8 +3,8 @@ cs: devise: confirmations: confirmed: Vaše e-mailová adresa byla úspěšně ověřena. - send_instructions: Za několik minut obdržíte e-mail s instrukcemi pro potvrzení vašeho účtu. Pokud tento e-mail neobdržíte, zkontrolujte si složku „spam“. - send_paranoid_instructions: Pokud tato e-mailová adresa existuje v naší databázi, obdržíte za několik minut e-mail s instrukcemi pro potvrzení vašeho účtu. Pokud tento e-mail neobdržíte, zkontrolujte si složku „spam“. + send_instructions: Za několik minut obdržíte e-mail s instrukcemi pro potvrzení vašeho účtu. Pokud tento e-mail neobdržíte, prosím zkontrolujte si složku „spam“. + send_paranoid_instructions: Pokud vaše e-mailová adresa existuje v naší databázi, obdržíte za několik minut e-mail s instrukcemi pro potvrzení vaší e-mailové adresy. Pokud tento e-mail neobdržíte, prosím zkontrolujte si složku „spam“. failure: already_authenticated: Již jste přihlášen/a. inactive: Váš účet ještě není aktivován. @@ -42,7 +42,7 @@ cs: action: Změnit heslo explanation: Vyžádal/a jste si pro svůj účet nové heslo. extra: Pokud jste tohle nevyžádal/a, prosím ignorujte tento e-mail. Vaše heslo nebude změněno, dokud nepřejdete na výše uvedenou adresu a nevytvoříte si nové. - subject: 'Mastodon: Instrukce pro obnovu hesla' + subject: 'Mastodon: Instrukce pro obnovení hesla' title: Obnovení hesla unlock_instructions: subject: 'Mastodon: Instrukce pro odemčení účtu' @@ -50,9 +50,9 @@ cs: failure: Nelze vás ověřit z %{kind}, protože „%{reason}“. success: Úspěšně ověřeno z účtu %{kind}. passwords: - no_token: Tuto stránku nemůžete navštívit, pokud nepřicházíte z e-mailu pro obnovu hesla. Pokud jste z něj přišel/la, ujistěte se, že jste použil/a celé URL z e-mailu. - send_instructions: Pokud vaše e-mailová adresa existuje v naší databázi, obdržíte za pár minut ve vašem e-mailu odkaz pro obnovení hesla. Prosím zkontrolujte si složku spam, jestli jste tento e-mail neobdržel/a. - send_paranoid_instructions: Pokud vaše e-mailová adresa existuje v naší databázi, obdržíte za pár minut ve vašem e-mailu odkaz pro obnovení hesla. Prosím zkontrolujte si složku spam, jestli jste tento e-mail neobdržel/a. + no_token: Tuto stránku nemůžete navštívit, pokud nepřicházíte z e-mailu pro obnovení hesla. Pokud z něj přicházíte, ujistěte se, že jste použil/a celé URL z e-mailu. + send_instructions: Pokud vaše e-mailová adresa existuje v naší databázi, obdržíte za několik minut ve vašem e-mailu odkaz pro obnovení hesla. Pokud tento e-mail neobdržíte, prosím zkontrolujte si složku „spam“. + send_paranoid_instructions: Pokud vaše e-mailová adresa existuje v naší databázi, obdržíte za několik minut ve vašem e-mailu odkaz pro obnovení hesla. Pokud tento e-mail neobdržíte, prosím zkontrolujte si složku „spam“. updated: Vaše heslo bylo úspěšně změněno. Nyní jste přihlášen/a. updated_not_active: Vaše heslo bylo úspěšně změněno. registrations: @@ -61,15 +61,15 @@ cs: signed_up_but_inactive: Registroval/a jste se úspěšně. Nemohli jsme vás však přihlásit, protože váš účet ještě není aktivován. signed_up_but_locked: Registroval/a jste se úspěšně. Nemohli jsme vás však přihlásit, protože váš účet je uzamčen. signed_up_but_unconfirmed: Na vaši e-mailovou adresu byla poslána zpráva s potvrzovacím odkazem. Pro aktivaci účtu přejděte na danou adresu. Pokud jste tento e-mail neobdržel/a, prosím zkontrolujte si složku spam. - update_needs_confirmation: Váš účet byl úspěšně aktualizován, ale je potřeba ověřit vaši novou e-mailovou adresu. Prosím zkontrolujte si e-mail a klikněte na odkaz pro potvrzení vaši nové e-mailové adresy. Pokud jste tento e-mail neobdržel/a, prosím zkontrolujte si složku spam. + update_needs_confirmation: Váš účet byl úspěšně aktualizován, ale je potřeba ověřit vaši novou e-mailovou adresu. Pokud tento e-mail neobdržíte, prosím zkontrolujte si složku „spam“. updated: Váš účet byl úspěšně aktualizován. sessions: already_signed_out: Odhlášení proběhlo úspěšně. signed_in: Přihlášení proběhlo úspěšně. signed_out: Odhlášení proběhlo úspěšně. unlocks: - send_instructions: Za pár minut obdržíte e-mail s instrukcemi pro odemčení vašeho účtu. Prosím zkontrolujte si složku spam, jestli jste tento e-mail neobdržel/a. - send_paranoid_instructions: Pokud váš účet existuje, obdržíte za pár minut e-mail s instrukcemi pro odemčení vašeho účtu. Prosím zkontrolujte si složku spam, jestli jste tento e-mail neobdržel/a. + send_instructions: Za několik minut obdržíte e-mail s instrukcemi pro odemčení vašeho účtu. Pokud tento e-mail neobdržíte, prosím zkontrolujte si složku „spam“. + send_paranoid_instructions: Pokud váš účet existuje, obdržíte za několik minut e-mail s instrukcemi pro odemčení vašeho účtu. Pokud tento e-mail neobdržíte, prosím zkontrolujte si složku „spam“. unlocked: Váš účet byl úspěšně odemčen. Pro pokračování se prosím přihlaste. errors: messages: diff --git a/config/locales/devise.eo.yml b/config/locales/devise.eo.yml index 71dd6c1ef..6c9fe4360 100644 --- a/config/locales/devise.eo.yml +++ b/config/locales/devise.eo.yml @@ -19,17 +19,17 @@ eo: confirmation_instructions: action: Konfirmi retadreson explanation: Vi kreis konton en %{host} per ĉi tiu retadreso. Nur klako restas por aktivigi ĝin. Se tio ne estis vi, bonvolu ignori ĉi tiun retmesaĝon. - extra_html: Bonvolu rigardi <a href="%{terms_path}">la regulojn de la nodo</a> kaj <a href="%{policy_path}">niajn uzkondiĉojn</a>. + extra_html: Bonvolu rigardi <a href="%{terms_path}">la regulojn de la servilo</a> kaj <a href="%{policy_path}">niajn uzkondiĉojn</a>. subject: 'Mastodon: Konfirmaj instrukcioj por %{instance}' title: Konfirmi retadreson email_changed: explanation: 'La retadreso de via konto ŝanĝiĝas al:' - extra: Se vi ne volis ŝanĝi vian retadreson, iu verŝajne aliris al via konto. Bonvolu tuj ŝanĝi vian pasvorton aŭ kontakti la administranton de la nodo, se vi estas blokita ekster via konto. + extra: Se vi ne volis ŝanĝi vian retadreson, iu verŝajne aliris al via konto. Bonvolu tuj ŝanĝi vian pasvorton aŭ kontakti la administranton de la servilo, se vi estas blokita ekster via konto. subject: 'Mastodon: Retadreso ŝanĝita' title: Nova retadreso password_change: explanation: La pasvorto de via konto estis ŝanĝita. - extra: Se vi ne ŝanĝis vian pasvorton, iu verŝajne aliris al via konto. Bonvolu ŝanĝi vian pasvorton tuj aŭ kontakti la administranton de la nodo, se vi estas blokita ekster via konto. + extra: Se vi ne ŝanĝis vian pasvorton, iu verŝajne aliris al via konto. Bonvolu ŝanĝi vian pasvorton tuj aŭ kontakti la administranton de la servilo, se vi estas blokita ekster via konto. subject: 'Mastodon: Pasvorto ŝanĝita' title: Pasvorto ŝanĝita reconfirmation_instructions: diff --git a/config/locales/devise.it.yml b/config/locales/devise.it.yml index 30266e46b..fc36fdbff 100644 --- a/config/locales/devise.it.yml +++ b/config/locales/devise.it.yml @@ -20,17 +20,17 @@ it: action: Verifica indirizzo email action_with_app: Conferma e torna a %{app} explanation: Hai creato un account su %{host} con questo indirizzo email. Sei lonatno solo un clic dall'attivarlo. Se non sei stato tu, per favore ignora questa email. - extra_html: Per favore controlla<a href="%{terms_path}">le regole dell'istanza</a> e <a href="%{policy_path}">i nostri termini di servizio</a>. + extra_html: Per favore controlla<a href="%{terms_path}">le regole del server</a> e <a href="%{policy_path}">i nostri termini di servizio</a>. subject: 'Mastodon: Istruzioni di conferma per %{instance}' title: Verifica indirizzo email email_changed: explanation: 'L''indirizzo email del tuo account sta per essere cambiato in:' - extra: Se non hai cambiato la tua email, è probabile che qualcuno abbia accesso al tuo account. Cambia immediatamente la tua password e contatta l'amministratore dell'istanza se sei bloccato fuori dal tuo account. + extra: Se non hai cambiato la tua email, è probabile che qualcuno abbia ottenuto l'accesso al tuo account. Cambia immediatamente la tua password e contatta l'amministratore del server se non puoi più accedere al tuo account. subject: 'Mastodon: Email cambiata' title: Nuovo indirizzo email password_change: explanation: La password del tuo account è stata cambiata. - extra: Se non hai cambiato la password, è probabile che qualcuno abbia accesso al tuo account. Cambia immediatamente la tua password e contatta l'amministratore dell'istanza se sei bloccato fuori dal tuo account. + extra: Se non hai cambiato la password, è probabile che qualcuno abbia ottenuto l'accesso al tuo account. Cambia immediatamente la tua password e contatta l'amministratore del server non puoi più accedere al tuo account. subject: 'Mastodon: Password modificata' title: Password cambiata reconfirmation_instructions: diff --git a/config/locales/devise.oc.yml b/config/locales/devise.oc.yml index 99809b858..e167f7e19 100644 --- a/config/locales/devise.oc.yml +++ b/config/locales/devise.oc.yml @@ -20,17 +20,17 @@ oc: action: Verificar l’adreça de corrièl action_with_app: Confirmar e tornar a %{app} explanation: Venètz de crear un compte sus %{host} amb aquesta adreça de corrièl. Vos manca pas qu’un clic per l’activar. S’èra pas vosautre mercés de far pas cas a aqueste messatge. - extra_html: Pensatz tanben de gaitar <a href="%{terms_path}">las règlas de l’instància</a> e <a href="%{policy_path}">nòstres tèrmes e condicions d’utilizacion</a>. + extra_html: Pensatz tanben de gaitar <a href="%{terms_path}">las règlas del servidor</a> e <a href="%{policy_path}">nòstres tèrmes e condicions d’utilizacion</a>. subject: 'Mastodon : consignas de confirmacion per %{instance}' title: Verificatz l’adreça de corrièl email_changed: explanation: 'L’adreça per aqueste compte es ara :' - extra: S’avètz pas demandat aqueste cambiament d’adreça, poiriá arribar que qualqu’un mai aguèsse agut accès a vòstre compte. Mercés de cambiar sulpic vòstre senhal o de contactar vòstre administrator d’instància se l’accès a vòstre compte vos es barrat. + extra: S’avètz pas demandat aqueste cambiament d’adreça, poiriá arribar que qualqu’un mai aguèsse agut accès a vòstre compte. Mercés de cambiar sulpic vòstre senhal o de contactar vòstre administrator de servidor se l’accès a vòstre compte vos es barrat. subject: 'Mastodon : corrièl cambiat' title: Nòva adreça de corrièl password_change: explanation: Lo senhal per vòstre compte a cambiat. - extra: S’avètz pas demandat aqueste cambiament de senhal, poiriá arribar que qualqu’un mai aguèsse agut accès a vòstre compte. Mercés de cambiar sulpic vòstre senhal o de contactar vòstre administrator d’instància se l’accès a vòstre compte vos es barrat. + extra: S’avètz pas demandat aqueste cambiament de senhal, poiriá arribar que qualqu’un mai aguèsse agut accès a vòstre compte. Mercés de cambiar sulpic vòstre senhal o de contactar vòstre administrator de servidor se l’accès a vòstre compte vos es barrat. subject: Mastodon : senhal cambiat title: Senhal cambiat reconfirmation_instructions: diff --git a/config/locales/devise.pt.yml b/config/locales/devise.pt.yml index 5fd54ff50..9b44bbf00 100644 --- a/config/locales/devise.pt.yml +++ b/config/locales/devise.pt.yml @@ -2,34 +2,35 @@ pt: devise: confirmations: - confirmed: O teu endereço de email foi confirmado. - send_instructions: Vais receber um email com as instruções para confirmar o teu endereço de email dentro de alguns minutos. - send_paranoid_instructions: Se o teu endereço de email já existir na nossa base de dados, vais receber um email com as instruções de confirmação dentro de alguns minutos. + confirmed: O teu endereço de e-mail foi confirmado com sucesso. + send_instructions: Vais receber um email com as instruções para confirmar o teu endereço de email dentro de alguns minutos. Por favor, verifica a caixa de spam se não recebeste o e-mail. + send_paranoid_instructions: Se o teu endereço de email já existir na nossa base de dados, vais receber um email com as instruções de confirmação dentro de alguns minutos. Por favor, verifica a caixa de spam se não recebeste o e-mail. failure: already_authenticated: A tua sessão já está aberta. inactive: A tua conta ainda não está ativada. - invalid: "%{authentication_keys} ou palavra-passe não válida." + invalid: "%{authentication_keys} ou palavra-passe inválida." last_attempt: Tens mais uma tentativa antes de a tua conta ficar bloqueada. locked: A tua conta está bloqueada. - not_found_in_database: "%{authentication_keys} ou palavra-passe não válida." + not_found_in_database: "%{authentication_keys} ou palavra-passe inválida." timeout: A tua sessão expirou. Por favor, entra de novo para continuares. - unauthenticated: Precisas de entrar na tua conta ou registares-te antes de continuar. + unauthenticated: Precisas de entrar na tua conta ou de te registares antes de continuar. unconfirmed: Tens de confirmar o teu endereço de email antes de continuar. mailer: confirmation_instructions: action: Verificar o endereço de e-mail + action_with_app: Confirmar e regressar a %{app} explanation: Criaste uma conta em %{host} com este endereço de e-mail. Estás a um clique de activá-la. Se não foste tu que fizeste este registo, por favor ignora esta mensagem. - extra_html: Por favor vê as <a href="%{terms_path}">as regras da instância</a> e os <a href="%{policy_path}">termos de serviço</a>. + extra_html: Por favor lê <a href="%{terms_path}">as regras da instância</a> e os <a href="%{policy_path}"> nossos termos de serviço</a>. subject: 'Mastodon: Instruções de confirmação %{instance}' title: Verificar o endereço de e-mail email_changed: explanation: 'O e-mail associado à tua conta será alterado para:' - extra: Se não alteraste o teu e-mail é possível que alguém tenha conseguido aceder à tua conta. Por favor muda a tua palavra-passe imediatamente ou entra em contato com um administrador da tua instância se ficaste sem acesso à tua conta. + extra: Se não alteraste o teu e-mail é possível que alguém tenha conseguido aceder à tua conta. Por favor muda a tua palavra-passe imediatamente ou entra em contato com um administrador do servidor se ficaste sem acesso à tua conta. subject: 'Mastodon: Email alterado' title: Novo endereço de e-mail password_change: explanation: A palavra-passe da tua conta foi alterada. - extra: Se não alteraste a tua palavra-passe, é possível que alguém tenha conseguido aceder à tua conta. Por favor muda a tua palavra-passe imediatamente ou entra em contato com um administrador da tua instância se ficaste sem acesso à tua conta. + extra: Se não alteraste a tua palavra-passe, é possível que alguém tenha conseguido aceder à tua conta. Por favor muda a tua palavra-passe imediatamente ou entra em contato com um administrador do servidor se ficaste sem acesso à tua conta. subject: 'Mastodon: Nova palavra-passe' title: Palavra-passe alterada reconfirmation_instructions: diff --git a/config/locales/doorkeeper.cs.yml b/config/locales/doorkeeper.cs.yml index 03b66a0fa..a303891c7 100644 --- a/config/locales/doorkeeper.cs.yml +++ b/config/locales/doorkeeper.cs.yml @@ -127,11 +127,11 @@ cs: read:notifications: vidět vaše oznámení read:reports: vidět vaše nahlášení read:search: vyhledávat za vás - read:statuses: vidět všechny příspěvky + read:statuses: vidět všechny tooty write: měnit všechna data vašeho účtu write:accounts: měnit váš profil write:blocks: blokovat účty a domény - write:favourites: oblibovat si příspěvky + write:favourites: oblibovat si tooty write:filters: vytvářet filtry write:follows: sledovat lidi write:lists: vytvářet seznamy @@ -139,4 +139,4 @@ cs: write:mutes: ignorovat lidi a konverzace write:notifications: vymazávat vaše oznámení write:reports: nahlašovat jiné uživatele - write:statuses: publikovat příspěvky + write:statuses: publikovat tooty diff --git a/config/locales/doorkeeper.kk.yml b/config/locales/doorkeeper.kk.yml index de3a0e155..409435802 100644 --- a/config/locales/doorkeeper.kk.yml +++ b/config/locales/doorkeeper.kk.yml @@ -4,8 +4,8 @@ kk: attributes: doorkeeper/application: name: Application аты - redirect_uri: Redirect URI - scopes: Scopes + redirect_uri: Redirеct URI + scopes: Scopеs website: Application сайты errors: models: diff --git a/config/locales/el.yml b/config/locales/el.yml index f3038e3d0..35da50af7 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -302,6 +302,7 @@ el: back_to_account: Επιστροφή στον λογαριασμό title: Ακόλουθοι του/της %{acct} instances: + by_domain: Τομέας delivery_available: Διαθέσιμη παράδοση known_accounts: one: "%{count} γνωστός λογαριασμός" @@ -423,10 +424,10 @@ el: desc_html: Εισαγωγική παράγραφος στην αρχική σελίδα. Περιέγραψε τι κάνει αυτό τον διακομιστή Mastodon διαφορετικό και ό,τι άλλο ενδιαφέρον. Μπορείς να χρησιμοποιήσεις HTML tags, συγκεκριμένα <code>< a></code> και <code> < em></code>. title: Περιγραφή κόμβου site_description_extended: - desc_html: Ένα καλό μέρος για τον κώδικα δεοντολογίας, τους κανόνες, τις οδηγίες και ό,τι άλλο διαφοροποιεί τον κόμβο σου. Δέχεται και κώδικα HTML + desc_html: Ένα καλό μέρος για τον κώδικα δεοντολογίας, τους κανόνες, τις οδηγίες και ό,τι άλλο διαφοροποιεί τον κόμβο σου. Μπορείς να χρησιμοποιήσεις και κώδικα HTML title: Προσαρμοσμένες εκτεταμένες πληροφορίες site_short_description: - desc_html: Εμφανίζεται στην πλαϊνή μπάρα και στα meta tags. Περιέγραψε τι είναι το Mastodon και τι κάνει αυτό τον διακομιστή ιδιαίτερο σε μια παράγραφο. Αν μείνει κενό, θα πάρει την προκαθορισμένη περιγραφή του κόμβου. + desc_html: Εμφανίζεται στην πλαϊνή μπάρα και στα meta tags. Περιέγραψε τι είναι το Mastodon και τι κάνει αυτό τον διακομιστή ιδιαίτερο σε μια παράγραφο. Αν μείνει κενό, θα χρησιμοποιήσει την προκαθορισμένη περιγραφή του κόμβου. title: Σύντομη περιγραφή του κόμβου site_terms: desc_html: Μπορείς να γράψεις τη δική σου πολιτική απορρήτου, όρους χρήσης ή άλλους νομικούς όρους. Μπορείς να χρησιμοποιήσεις HTML tags @@ -564,7 +565,7 @@ el: errors: '403': Δεν έχεις δικαίωμα πρόσβασης σε αυτή τη σελίδα. '404': Η σελίδα που ψάχνεις δεν υπάρχει. - '410': Η σελίδα που έψαχνες δεν υπάρχει πια. + '410': Η σελίδα που έψαχνες δεν υπάρχει πια εδώ. '422': content: Απέτυχε η επιβεβαίωση ασφαλείας. Μήπως μπλοκάρεις τα cookies; title: Η επιβεβαίωση ασφαλείας απέτυχε @@ -732,6 +733,16 @@ el: older: Παλιότερο prev: Προηγούμενο truncate: "…" + polls: + errors: + already_voted: Έχεις ήδη ψηφίσει σε αυτή την ψηφοφορία + duplicate_options: περιέχει επαναλαμβανόμενες επιλογές + duration_too_long: είναι πολύ μακριά στο μέλλον + duration_too_short: είναι πολύ σύντομα + expired: Η ψηφοφορία έχει ήδη λήξει + over_character_limit: δε μπορεί να υπερβαίνει τους %{max} χαρακτήρες έκαστη + too_few_options: πρέπει να έχει περισσότερες από μια επιλογές + too_many_options: δεν μπορεί να έχει περισσότερες από %{max} επιλογές preferences: languages: Γλώσσες other: Άλλο @@ -841,6 +852,11 @@ el: ownership: Δεν μπορείς να καρφιτσώσεις μη δικό σου τουτ private: Τα μη δημόσια τουτ δεν καρφιτσώνονται reblog: Οι προωθήσεις δεν καρφιτσώνονται + poll: + total_votes: + one: "%{count} ψήφος" + other: "%{count} ψήφοι" + vote: Ψήφισε show_more: Δείξε περισσότερα sign_in_to_participate: Εγγράφου για να συμμετάσχεις στη συζήτηση title: '%{name}: "%{quote}"' @@ -939,9 +955,9 @@ el: <p>Οι παραπάνω όροι έχουν προσαρμοστεί από τους αντίστοιχους όρους του <a href="https://github.com/discourse/discourse">Discourse</a>.</p> title: Όροι Χρήσης και Πολιτική Απορρήτου του κόμβου %{instance} themes: - contrast: Υψηλή αντίθεση - default: Mastodon - mastodon-light: Mastodon (ανοιχτόχρωμο) + contrast: Mastodon (Υψηλή αντίθεση) + default: Mastodon (Σκοτεινό) + mastodon-light: Mastodon (Ανοιχτόχρωμο) time: formats: default: "%b %d, %Y, %H:%M" diff --git a/config/locales/en.yml b/config/locales/en.yml index 1121ef3db..b77387890 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -738,6 +738,16 @@ en: older: Older prev: Prev truncate: "…" + polls: + errors: + already_voted: You have already voted on this poll + duplicate_options: contain duplicate items + duration_too_long: is too far into the future + duration_too_short: is too soon + expired: The poll has already ended + over_character_limit: cannot be longer than %{max} characters each + too_few_options: must have more than one item + too_many_options: can't contain more than %{max} items preferences: languages: Languages other: Other @@ -848,6 +858,11 @@ en: ownership: Someone else's toot cannot be pinned private: Non-public toot cannot be pinned reblog: A boost cannot be pinned + poll: + total_votes: + one: "%{count} vote" + other: "%{count} votes" + vote: Vote show_more: Show more sign_in_to_participate: Sign in to participate in the conversation title: '%{name}: "%{quote}"' diff --git a/config/locales/eo.yml b/config/locales/eo.yml index b7dd7ca8b..4e404d9eb 100644 --- a/config/locales/eo.yml +++ b/config/locales/eo.yml @@ -7,7 +7,7 @@ eo: administered_by: 'Administrata de:' api: API apps: Poŝtelefonaj aplikaĵoj - closed_registrations: Registriĝoj estas nuntempe fermitaj en ĉi tiu nodo. Tamen, vi povas trovi alian nodon por fari konton kaj aliri al la sama reto de tie. + closed_registrations: Registriĝoj estas nuntempe fermitaj en ĉi tiu servilo. Tamen, vi povas trovi alian servilon por fari konton kaj aliri al la sama reto de tie. contact: Kontakti contact_missing: Ne elektita contact_unavailable: Ne disponebla @@ -27,7 +27,7 @@ eo: generic_description: "%{domain} estas unu servilo en la reto" hosted_on: "%{domain} estas nodo de Mastodon" learn_more: Lerni pli - other_instances: Listo de nodoj + other_instances: Listo de serviloj privacy_policy: Privateca politiko source_code: Fontkodo status_count_after: @@ -70,6 +70,9 @@ eo: moderator: Kontrolanto unfollow: Ne plu sekvi admin: + account_actions: + action: Plenumi agon + title: Plenumi kontrolan agon al %{acct} account_moderation_notes: create: Lasi noton created_msg: Kontrola noto sukcese kreita! @@ -89,6 +92,7 @@ eo: confirm: Konfirmi confirmed: Konfirmita confirming: Konfirmante + deleted: Forigita demote: Degradi disable: Malebligi disable_two_factor_authentication: Malebligi 2FA @@ -96,26 +100,29 @@ eo: display_name: Montrata nomo domain: Domajno edit: Redakti - email: Retpoŝto - email_status: Retpoŝto Stato + email: Retadreso + email_status: Retadreso Stato enable: Ebligi enabled: Ebligita feed_url: URL de la fluo followers: Sekvantoj followers_url: URL de la sekvantoj follows: Sekvatoj + header: Kapa bildo inbox_url: Enira URL + invited_by: Invitita de ip: IP + joined: Aliĝis location: all: Ĉio - local: Loka - remote: Fora + local: Lokaj + remote: Foraj title: Loko login_status: Ensaluta stato media_attachments: Ligitaj aŭdovidaĵoj memorialize: Ŝanĝi al memoro moderation: - active: Aktiva + active: Aktivaj all: Ĉio silenced: Silentigitaj suspended: Haltigitaj @@ -132,8 +139,9 @@ eo: protocol: Protokolo public: Publika push_subscription_expires: Eksvalidiĝo de la abono al PuSH - redownload: Aktualigi profilbildon + redownload: Aktualigi profilon remove_avatar: Forigi profilbildon + remove_header: Forigi kapan bildon resend_confirmation: already_confirmed: Ĉi tiu uzanto jam estas konfirmita send: Esend konfirmi retpoŝton @@ -151,8 +159,8 @@ eo: search: Serĉi shared_inbox_url: URL de kunhavigita leterkesto show: - created_reports: Signaloj kreitaj de ĉi tiu konto - targeted_reports: Signaloj kreitaj de ĉi tiu konto + created_reports: Kreitaj signaloj + targeted_reports: Signalitaj de aliaj silence: Kaŝi silenced: Silentigita statuses: Mesaĝoj @@ -164,12 +172,14 @@ eo: undo_suspension: Malfari haltigon unsubscribe: Malaboni username: Uzantnomo + warn: Averti web: Reto action_logs: actions: assigned_to_self_report: "%{name} asignis signalon %{target} al si mem" change_email_user: "%{name} ŝanĝis retadreson de uzanto %{target}" confirm_user: "%{name} konfirmis retadreson de uzanto %{target}" + create_account_warning: "%{name} sendis averton al %{target}" create_custom_emoji: "%{name} alŝutis novan emoĝion %{target}" create_domain_block: "%{name} blokis domajnon %{target}" create_email_domain_block: "%{name} metis en nigran liston domajnon %{target}" @@ -261,6 +271,13 @@ eo: title: Nova domajna blokado reject_media: Malakcepti aŭdovidajn dosierojn reject_media_hint: Forigas aŭdovidaĵojn loke konservitajn kaj rifuzas alŝuti ajnan estonte. Senzorge pri haltigoj + reject_reports: Malakcepti raportojn + reject_reports_hint: Ignori ĉiujn raportojn el tiu domajno. Nur gravas por silentigoj + rejecting_media: aŭdovidaj dosieroj malakceptiĝas + rejecting_reports: raportoj malakceptiĝas + severity: + silence: silentigita + suspend: haltigita show: affected_accounts: one: Unu konto en la datumbazo esta influita @@ -281,8 +298,25 @@ eo: create: Aldoni domajnon title: Nova blokado de retadresa domajno title: Nigra listo de retadresaj domajnoj + followers: + back_to_account: Reen al la konto + title: Sekvantoj de %{acct} instances: - title: Konataj nodoj + by_domain: Domajno + delivery_available: Liverado disponeblas + known_accounts: + one: "%{count} konata konto" + other: "%{count} konataj kontoj" + moderation: + all: Ĉiuj + limited: Limigita + title: Kontrolo + title: Federacio + total_blocked_by_us: Blokitaj de ni + total_followed_by_them: Sekvataj de ili + total_followed_by_us: Sekvataj de ni + total_reported: Raportoj pri ili + total_storage: Aŭdovidaj kunsendaĵoj invites: deactivate_all: Malaktivigi ĉion filter: @@ -301,6 +335,7 @@ eo: enable_hint: Post ebligo, via servilo abonos ĉiujn publikajn mesaĝojn de tiu ripetilo, kaj komencos sendi publikajn mesaĝojn de la servilo al ĝi. enabled: Malebligita inbox_url: URL de la ripetilo + pending: Atendante aprobon de la ripetilo save_and_enable: Konservi kaj ebligi setup: Agordi konekton al ripetilo status: Stato @@ -331,12 +366,12 @@ eo: report: 'Signalo #%{id}' reported_account: Signalita konto reported_by: Signalita de - resolved: Solvita + resolved: Solvitaj resolved_msg: Signalo sukcese solvita! status: Mesaĝoj title: Signaloj unassign: Malasigni - unresolved: Nesolvita + unresolved: Nesolvitaj updated_at: Ĝisdatigita settings: activity_api_enabled: @@ -352,11 +387,14 @@ eo: desc_html: Ŝanĝi la aspekton per CSS ŝargita en ĉiu pago title: Propra CSS hero: - desc_html: Montrata en la ĉefpaĝo. Almenaŭ 600x100px rekomendita. Kiam ne agordita, la bildeto de la nodo estos uzata + desc_html: Montrata en la ĉefpaĝo. Almenaŭ 600x100px rekomendita. Kiam ne agordita, la bildeto de la servilo estos uzata title: Kapbildo + mascot: + desc_html: Montrata en pluraj paĝoj. Rekomendataj estas almenaŭ 293x205px. Se ĉi tio ne estas agordita, la defaŭlta maskoto uziĝas + title: Maskota bildo peers_api_enabled: - desc_html: Nomoj de domajnoj, kiujn ĉi tiu nodo renkontis en la fediverse - title: Publikigi liston de malkovritaj nodoj + desc_html: Nomoj de domajnoj, kiujn ĉi tiu servilo renkontis en la federauniverso + title: Publikigi liston de malkovritaj serviloj preview_sensitive_media: desc_html: Antaŭvido de ligiloj en aliaj retejoj montros bildeton eĉ se la aŭdovidaĵo estas markita kiel tikla title: Montri tiklajn aŭdovidaĵojn en la antaŭvidoj de OpenGraph @@ -384,20 +422,20 @@ eo: title: Montri teaman insignon site_description: desc_html: Enkonduka alineo en la ĉefpaĝo. Priskribu la unikaĵojn de ĉi tiu nodo de Mastodon, kaj ĉiujn aliajn gravaĵojn. Vi povas uzi HTML-etikedojn, kiel <code><a></code> kaj <code><em></code>. - title: Priskribo de la nodo + title: Priskribo de la servilo site_description_extended: - desc_html: Bona loko por viaj sintenaj reguloj, aliaj reguloj, gvidlinioj kaj aliaj aferoj, kiuj apartigas vian nodon. Vi povas uzi HTML-etikedojn + desc_html: Bona loko por viaj sintenaj reguloj, aliaj reguloj, gvidlinioj kaj aliaj aferoj, kiuj apartigas vian serilon. Vi povas uzi HTML-etikedojn title: Propraj detalaj informoj site_short_description: - desc_html: Afiŝita en la flankpanelo kaj metadatumaj etikedoj. Priskribu kio estas Mastodon, kaj kio specialas en ĉi tiu nodo, per unu alineo. Se malplena, la priskribo de la nodo estos uzata. - title: Mallonga priskribo de la nodo + desc_html: Afiŝita en la flankpanelo kaj metadatumaj etikedoj. Priskribu kio estas Mastodon, kaj kio specialas en ĉi tiu nodo, per unu alineo. Se malplena, la priskribo de la servilo estos uzata. + title: Mallonga priskribo de la servilo site_terms: desc_html: Vi povas skribi vian propran privatecan politikon, viajn uzkondiĉojn aŭ aliajn leĝaĵojn. Vi povas uzi HTML-etikedojn title: Propraj uzkondiĉoj - site_title: Nomo de la nodo + site_title: Nomo de la servilo thumbnail: desc_html: Uzata por antaŭvidoj per OpenGraph kaj per API. 1200x630px rekomendita - title: Bildeto de la nodo + title: Bildeto de la servilo timeline_preview: desc_html: Montri publikan tempolinion en komenca paĝo title: Tempolinia antaŭvido @@ -423,12 +461,20 @@ eo: title: WebSub topic: Temo tags: + accounts: Kontoj + hidden: Kaŝitaj hide: Kaŝi de la profilujo name: Kradvorto title: Kradvortoj unhide: Montri en la profilujo - visible: Videbla + visible: Videblaj title: Administrado + warning_presets: + add_new: Aldoni novan + delete: Forigi + edit: Redakti + edit_preset: Redakti avertan antaŭagordon + title: Administri avertajn antaŭagordojn admin_mailer: new_report: body: "%{reporter} signalis %{target}" @@ -450,7 +496,7 @@ eo: warning: Estu tre atenta kun ĉi tiu datumo. Neniam diskonigu ĝin al iu ajn! your_token: Via alira ĵetono auth: - agreement_html: Klakante “Registriĝi” sube, vi konsentas kun <a href="%{rules_path}">la reguloj de la nodo</a> kaj <a href="%{terms_path}">niaj uzkondiĉoj</a>. + agreement_html: Klakante “Registriĝi” sube, vi konsentas kun <a href="%{rules_path}">la reguloj de la servilo</a> kaj <a href="%{terms_path}">niaj uzkondiĉoj</a>. change_password: Pasvorto confirm_email: Konfirmi retadreson delete_account: Forigi konton @@ -504,19 +550,22 @@ eo: description_html: Tio <strong>porĉiame kaj neŝanĝeble</strong> forigos la enhavon de via konto kaj malaktivigos ĝin. Via uzantnomo restos rezervita por eviti postajn trompojn pri identeco. proceed: Forigi konton success_msg: Via konto estis sukcese forigita - warning_html: La forigo de la enhavo estas certa nur por ĉi tiu aparta nodo. Enhavo, kiu estis disvastigita verŝajne lasos spurojn. Eksterretaj serviloj kaj serviloj, kiuj ne abonas viajn ĝisdatigojn ne ĝisdatigos siajn datumbazojn. + warning_html: La forigo de la enhavo estas certa nur por ĉi tiu aparta servilo. Enhavo, kiu estis disvastigita verŝajne lasos spurojn. Eksterretaj serviloj kaj serviloj, kiuj ne abonas viajn ĝisdatigojn ne ĝisdatigos siajn datumbazojn. warning_title: Disponebleco de disvastigita enhavo directories: directory: Profilujo + enabled: Vi estas listigata en la profilujo. + enabled_but_waiting: Vi elektis esti listigata en la profilujo, sed vi ankoraŭ ne havas la minimuman kvanton da sekvantoj (%{min_followers}) por esti listigata. explanation: Malkovru uzantojn per iliaj interesoj explore_mastodon: Esplori %{title} + how_to_enable: Vi ankoraŭ ne donis permeson listigi vin en la profilujo. Vi povas doni permeson ĉi-sube. Uzu kradvortojn en via biografia teksto por esti listigata sub specifaj kradvortoj! people: one: "%{count} personoj" other: "%{count} personoj" errors: '403': Vi ne havas la rajton por vidi ĉi tiun paĝon. - '404': La paĝo, kiun vi serĉas, ne ekzistas. - '410': La paĝo, kiun vi serĉas, ne plu ekzistas. + '404': La paĝo ke kiun vi serĉas ne ekzistas ĉi tie. + '410': La paĝo, kiun vi serĉas, ne plu ekzistas ĉi tie. '422': content: Sekureca konfirmo malsukcesa. Ĉu vi blokas kuketojn? title: Sekureca konfirmo malsukcesa @@ -537,9 +586,15 @@ eo: size: Grandeco blocks: Vi blokas csv: CSV + domain_blocks: Blokoj de domajnoj follows: Vi sekvas + lists: Listoj mutes: Vi silentigas storage: Aŭdovidaĵa konservado + featured_tags: + add_new: Aldoni novan + errors: + limit: Vi jam elstarigis la maksimuman kvanton da kradvortoj filters: contexts: home: Hejma tempolinio @@ -558,7 +613,7 @@ eo: title: Aldoni novan filtrilon followers: domain: Domajno - explanation_html: Se vi volas esti certa pri la privateco de viaj mesaĝoj, vi bezonas esti atenta pri tiuj, kiuj sekvas vin. <strong>Viaj privataj mesaĝoj estas liveritaj al ĉiuj nodoj, kie vi havas sekvantojn</strong>. Eble vi ŝatus kontroli ilin, kaj forigi la sekvantojn de la nodoj, kie vi ne certas ĉu via privateco estos respektita de la tiea teamo aŭ programo. + explanation_html: Se vi volas esti certa pri la privateco de viaj mesaĝoj, vi bezonas esti atenta pri tiuj, kiuj sekvas vin. <strong>Viaj privataj mesaĝoj estas liveritaj al ĉiuj serviloj, kie vi havas sekvantojn</strong>. Eble vi ŝatus kontroli ilin, kaj forigi la sekvantojn de la serviloj, kie vi ne certas ĉu via privateco estos respektita de la tiea teamo aŭ programo. followers_count: Nombro de sekvantoj lock_link: Ŝlosu vian konton purge: Forigi el la sekvantoj @@ -580,10 +635,16 @@ eo: one: Io mise okazis! Bonvolu konsulti la suban erar-raporton other: Io mise okazis! Bonvolu konsulti la subajn %{count} erar-raportojn imports: - preface: Vi povas importi datumojn, kiujn vi eksportis el alia nodo, kiel liston de homoj, kiujn vi sekvas aŭ blokas. + modes: + merge: Kunigi + merge_long: Konservi ekzistajn registrojn kaj aldoni novajn + overwrite: Anstataŭigi + overwrite_long: Anstataŭigi la nunajn registrojn per la novaj + preface: Vi povas importi datumojn, kiujn vi eksportis el alia servilo, kiel liston de homoj, kiujn vi sekvas aŭ blokas. success: Viaj datumoj estis sukcese alŝutitaj kaj estos traktitaj kiel planite types: blocking: Listo de blokitoj + domain_blocking: Listo de blokitaj domajnoj following: Listo de sekvatoj muting: Listo de silentigitoj upload: Alŝuti @@ -605,7 +666,7 @@ eo: one: 1 uzo other: "%{count} uzoj" max_uses_prompt: Neniu limo - prompt: Krei kaj diskonigi ligilojn al aliaj por doni aliron al ĉi tiu nodo + prompt: Krei kaj diskonigi ligilojn al aliaj por doni aliron al ĉi tiu servilo table: expires_at: Eksvalidiĝas je uses: Uzoj @@ -686,10 +747,25 @@ eo: no_account_html: Ĉu vi ne havas konton? Vi povas <a href='%{sign_up_path}' target='_blank'>registriĝi tie</a> proceed: Daŭrigi por eksekvi prompt: 'Vi eksekvos:' + reason_html: "<strong>Kial necesas ĉi tiu paŝo?</strong><code>%{instance}</code> povus ne esti la servilo, kie vi registriĝis, do ni unue bezonas alidirekti vin al via hejma servilo." + remote_interaction: + favourite: + proceed: Konfirmi la stelumon + prompt: 'Vi volas stelumi ĉi tiun mesaĝon:' + reblog: + proceed: Konfirmi la diskonigon + prompt: 'Vi volas diskonigi ĉi tiun mesaĝon:' + reply: + proceed: Konfirmi la respondon + prompt: 'Vi volas respondi al ĉi tiu mesaĝo:' remote_unfollow: error: Eraro title: Titolo unfollowed: Ne plu sekvita + scheduled_statuses: + over_daily_limit: Vi transpasis la limigon al %{limit} samtage planitaj mesaĝoj + over_total_limit: Vi transpasis la limigon al %{limit} planitaj mesaĝoj + too_soon: La planita dato devas esti en la estonteco sessions: activity: Lasta ago browser: Retumilo @@ -738,6 +814,7 @@ eo: development: Evoluigado edit_profile: Redakti profilon export: Eksporti datumojn + featured_tags: Elstarigitaj kradvortoj followers: Rajtigitaj sekvantoj import: Importi migrate: Konta migrado @@ -785,9 +862,9 @@ eo: terms: title: Uzkondiĉoj kaj privateca politiko de %{instance} themes: - contrast: Forta kontrasto - default: Mastodon - mastodon-light: Mastodon (hela) + contrast: Mastodon (Forta kontrasto) + default: Mastodon (Malluma) + mastodon-light: Mastodon (Luma) time: formats: default: "%Y-%m-%d %H:%M" @@ -813,6 +890,22 @@ eo: explanation: Vi petis kompletan arkivon de via Mastodon-konto. Ĝi nun pretas por elŝutado! subject: Via arkivo estas preta por elŝutado title: Arkiva elŝuto + warning: + explanation: + disable: Dum via konto estas frostigita, via kontaj datumoj restas intaktaj, sed vi ne povas plenumi iujn agojn ĝis ĝi estas malhaltigita. + silence: Dum via konto estas limigita, nur tiuj, kiuj jam sekvas vin, vidos viajn mesaĝojn en ĉi tiu servilo, kaj vi povus esti ekskludita de diversaj publikaj listoj. Tamen, aliaj ankoraŭ povas mane sekvi vin. + suspend: Via konto estis haltigita, kaj ĉiuj el viaj mesaĝoj kaj alŝutitaj aŭdovidaj dosieroj estis nemalfareble forigitaj de ĉi tiu servilo, kaj de la serviloj, kie vi havis sekvantojn. + review_server_policies: Superrigardi servilajn politikojn + subject: + disable: Via konto %{acct} estas frostigita + none: Averto por %{acct} + silence: Via konto %{acct} estas limigita + suspend: Via konto %{acct} estas haltigita + title: + disable: Konto frostigita + none: Averto + silence: Konto limigita + suspend: Konto haltigita welcome: edit_profile_action: Agordi profilon edit_profile_step: Vi povas proprigi vian profilon per alŝuto de profilbildo, fonbildo, ŝanĝo de via afiŝita nomo kaj pli. Se vi ŝatus kontroli novajn sekvantojn antaŭ ol ili rajtas sekvi vin, vi povas ŝlosi vian konton. @@ -820,7 +913,7 @@ eo: final_action: Ekmesaĝi final_step: 'Ekmesaĝu! Eĉ sen sekvantoj, viaj publikaj mesaĝoj povas esti vidataj de aliaj, ekzemple en la loka tempolinio kaj en la kradvortoj. Eble vi ŝatus prezenti vin per la kradvorto #introductions.' full_handle: Via kompleta uzantnomo - full_handle_hint: Jen kion vi dirus al viaj amikoj, por ke ili mesaĝu aŭ sekvu vin de alia nodo. + full_handle_hint: Jen kion vi dirus al viaj amikoj, por ke ili mesaĝu aŭ sekvu vin de alia servilo. review_preferences_action: Ŝanĝi preferojn review_preferences_step: Estu certa ke vi agordis viajn preferojn, kiel kiujn retmesaĝojn vi ŝatus ricevi, aŭ kiun dekomencan privatecan nivelon vi ŝatus ke viaj mesaĝoj havu. Se tio ne ĝenas vin, vi povas ebligi aŭtomatan ekigon de GIF-oj. subject: Bonvenon en Mastodon @@ -838,4 +931,5 @@ eo: seamless_external_login: Vi estas ensalutinta per ekstera servo, do pasvortaj kaj retadresaj agordoj ne estas disponeblaj. signed_in_as: 'Ensalutinta kiel:' verification: + explanation_html: 'Vi povas <strong>pruvi, ke vi estas la posedanto de la ligiloj en viaj profilaj metadatumoj</strong>. Por fari tion, la alligita retejo devas enhavi ligilon reen al via Mastodon-profilo. La religilo <strong>devas</strong> havi la atributon <code>rel="me"</code>. Ne gravas la teksta enhavo de la religilo. Jen ekzemplo:' verification: Kontrolo diff --git a/config/locales/es.yml b/config/locales/es.yml index 6ebf1a78f..b7be2b365 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -7,7 +7,7 @@ es: administered_by: 'Administrado por:' api: API apps: Aplicaciones móviles - closed_registrations: Los registros están actualmente cerrados en esta instancia. + closed_registrations: Los registros están actualmente cerrados en este servidor. Aun así, puedes encontrar un servidor diferente para registrarte y tener acceso a la misma comunidad contact: Contacto contact_missing: No especificado contact_unavailable: N/A @@ -48,6 +48,7 @@ es: other: Seguidores following: Siguiendo joined: Se unió el %{date} + last_active: última conexión link_verified_on: La propiedad de este vínculo fue verificada el %{date} media: Media moved_html: "%{name} se ha trasladado a %{new_profile_link}:" @@ -69,6 +70,8 @@ es: moderator: Moderador unfollow: Dejar de seguir admin: + account_actions: + title: Moderar %{acct} account_moderation_notes: create: Crear created_msg: "¡Nota de moderación creada con éxito!" @@ -88,6 +91,7 @@ es: confirm: Confirmar confirmed: Confirmado confirming: Confirmando + deleted: Borrado demote: Degradar disable: Deshabilitar disable_two_factor_authentication: Desactivar autenticación de dos factores diff --git a/config/locales/fa.yml b/config/locales/fa.yml index fd551d1b6..4214e793c 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -61,7 +61,7 @@ fa: posts: one: بوق other: بوق - posts_tab_heading: بوقها + posts_tab_heading: نوشتهها posts_with_replies: نوشتهها و پاسخها reserved_username: این نام کاربری در دسترس نیست roles: @@ -302,6 +302,7 @@ fa: back_to_account: بازگشت به حساب title: پیگیران %{acct} instances: + by_domain: دامین delivery_available: پیام آماده است known_accounts: one: "%{count} حساب شناختهشده" @@ -733,6 +734,16 @@ fa: older: قدیمیتر prev: قبلی truncate: "…" + polls: + errors: + already_voted: شما قبلاً در این نظرسنجی رأی دادهاید + duplicate_options: دارای موارد تکراری است + duration_too_long: در آیندهٔ خیلی دور است + duration_too_short: در آیندهٔ خیلی نزدیک است + expired: این نظرسنجی به پایان رسیده است + over_character_limit: هر کدام نمیتواند از %{max} نویسه طولانیتر باشد + too_few_options: حتماً باید بیش از یک گزینه داشته باشد + too_many_options: نمیتواند بیشتر از %{max} گزینه داشته باشد preferences: languages: تنظیمات زبان other: سایر تنظیمات diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 5c564fc04..b9d813e9e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -264,7 +264,7 @@ fr: create: Créer le blocage 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>Suspendre</strong> supprimera tout le contenu des comptes concernés, les médias, et les données du profil. Utilisez <strong>Aucun</strong> si vous voulez simplement rejeter les fichiers multimédia." + desc_html: "<strong>Masqué</strong> rendra les messages des comptes concernés invisibles à ceux qui ne les suivent pas. <strong>Suspendre</strong> supprimera tout le contenu des comptes concernés, les médias, et les données du profil. Utilisez <strong>Aucune</strong> si vous voulez simplement rejeter les fichiers multimédia." noop: Aucune silence: Masqué suspend: Suspendre @@ -302,6 +302,7 @@ fr: back_to_account: Retour au compte title: Abonné⋅e⋅s de %{acct} instances: + by_domain: Domaine delivery_available: Livraison disponible known_accounts: one: "%{count} compte connu" @@ -733,6 +734,16 @@ fr: older: Plus ancien prev: Précédent truncate: "…" + polls: + errors: + already_voted: Vous avez déjà voté sur ce sondage + duplicate_options: contient des doublons + duration_too_long: est trop loin dans le futur + duration_too_short: est trop tôt + expired: Ce sondage est déjà terminé + over_character_limit: ne peuvent être plus long que %{max} caractères chacun + too_few_options: doit avoir plus qu'une proposition + too_many_options: ne peut contenir plus que %{max} propositions preferences: languages: Langues other: Autre diff --git a/config/locales/gl.yml b/config/locales/gl.yml index cadb7cff6..2435137f9 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -302,6 +302,7 @@ gl: back_to_account: Voltar a Conta title: Seguidoras de %{acct} instances: + by_domain: Dominio delivery_available: A entrega está dispoñible known_accounts: one: "%{count} conta coñecida" @@ -733,6 +734,16 @@ gl: older: Máis antigo prev: Previo truncate: "…" + polls: + errors: + already_voted: Xa votou en esta sondaxe + duplicate_options: contén elementos duplicados + duration_too_long: está moi lonxe no futuro + duration_too_short: é demasiado cedo + expired: A sondaxe rematou + over_character_limit: non poden ter máis de %{max} caracteres cada unha + too_few_options: debe ter máis de unha opción + too_many_options: non pode haber máis de %{max} opcións preferences: languages: Idiomas other: Outro @@ -842,6 +853,11 @@ gl: ownership: Non pode fixar a mensaxe de outra usuaria private: As mensaxes non-públicas non poden ser fixadas reblog: Non se poden fixar as mensaxes promovidas + poll: + total_votes: + one: "%{count} voto" + other: "%{count} votos" + vote: Votar show_more: Mostrar máis sign_in_to_participate: Conéctese para participar na conversa title: '%{name}: "%{quote}"' diff --git a/config/locales/it.yml b/config/locales/it.yml index fea39b1fd..76fcb2b91 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -7,7 +7,7 @@ it: administered_by: 'Amministrato da:' api: API apps: Applicazioni Mobile - closed_registrations: Al momento le iscrizioni a questo server sono chiuse. Tuttavia! Puoi provare a cercare un istanza diversa su cui creare un account ed avere accesso alla stessa identica rete. + closed_registrations: Al momento le iscrizioni a questo server sono chiuse. Tuttavia! Puoi provare a cercare un server diverso su cui creare un account ed avere accesso alla stessa identica rete. contact: Contatti contact_missing: Non impostato contact_unavailable: N/D @@ -27,7 +27,7 @@ it: generic_description: "%{domain} è un server nella rete" hosted_on: Mastodon ospitato su %{domain} learn_more: Scopri altro - other_instances: Elenco istanze + other_instances: Elenco server privacy_policy: Politica della privacy source_code: Codice sorgente status_count_after: @@ -69,6 +69,8 @@ it: moderator: Moderatore unfollow: Non seguire più admin: + account_actions: + action: Esegui azione account_moderation_notes: create: Lascia nota created_msg: Nota di moderazione creata con successo! @@ -88,6 +90,7 @@ it: confirm: Conferma confirmed: Confermato confirming: Confermando + deleted: Cancellato demote: Declassa disable: Disabilita disable_two_factor_authentication: Disabilita 2FA @@ -103,7 +106,9 @@ it: followers: Follower followers_url: URL follower follows: Segue + header: Intestazione inbox_url: URL inbox + invited_by: Invitato da ip: IP location: all: Tutto @@ -114,6 +119,7 @@ it: media_attachments: Media allegati memorialize: Trasforma in memoriam moderation: + active: Attivo all: Tutto silenced: Silenziati suspended: Sospesi @@ -132,6 +138,7 @@ it: push_subscription_expires: Sottoscrizione PuSH scaduta redownload: Aggiorna avatar remove_avatar: Rimuovi avatar + remove_header: Rimuovi intestazione resend_confirmation: already_confirmed: Questo utente è già confermato send: Reinvia email di conferma @@ -162,12 +169,14 @@ it: undo_suspension: Rimuovi sospensione unsubscribe: Annulla l'iscrizione username: Nome utente + warn: Avverti web: Web action_logs: actions: assigned_to_self_report: "%{name} ha assegnato il rapporto %{target} a se stesso" change_email_user: "%{name} ha cambiato l'indirizzo email per l'utente %{target}" confirm_user: "%{name} ha confermato l'indirizzo email per l'utente %{target}" + create_account_warning: "%{name} ha mandato un avvertimento a %{target}" create_custom_emoji: "%{name} ha caricato un nuovo emoji %{target}" create_domain_block: "%{name} ha bloccato il dominio %{target}" create_email_domain_block: "%{name} ha messo il dominio email %{target} nella blacklist" @@ -260,6 +269,9 @@ it: reject_media_hint: Rimuovi i file media salvati in locale e blocca i download futuri. Irrilevante per le sospensioni reject_reports: Respingi rapporti reject_reports_hint: Ignora tutti i rapporti provenienti da questo dominio. Irrilevante per sospensioni + severity: + silence: silenziato + suspend: sospeso show: affected_accounts: one: Interessato un solo account nel database @@ -280,8 +292,22 @@ it: create: Aggiungi dominio title: Nuova voce della lista nera delle email title: Lista nera email + followers: + back_to_account: Torna all'account + title: Seguaci di %{acct} instances: + by_domain: Dominio + known_accounts: + one: "%{count} account noto" + other: "%{count} account noti" + moderation: + limited: Limitato + title: Moderazione title: Istanze conosciute + total_blocked_by_us: Bloccato da noi + total_followed_by_them: Seguito da loro + total_followed_by_us: Seguito da noi + total_storage: Media allegati invites: deactivate_all: Disattiva tutto filter: @@ -351,17 +377,19 @@ it: desc_html: Modifica l'aspetto con il CSS caricato in ogni pagina title: CSS personalizzato hero: - desc_html: Mostrata nella pagina iniziale. Almeno 600x100 px consigliati. Se non impostata, sarà usato il thumbnail dell'istanza + desc_html: Mostrata nella pagina iniziale. Almeno 600x100 px consigliati. Se non impostata, sarà usato il thumbnail del server title: Immagine dell'eroe mascot: desc_html: Mostrata su più pagine. Almeno 293×205px consigliati. Se non impostata, sarò usata la mascotte predefinita title: Immagine della mascotte peers_api_enabled: - desc_html: Nomi di dominio che questa istanza ha incontrato nella fediverse - title: Pubblica elenco di istanze scoperte + desc_html: Nomi di dominio che questo server ha incontrato nel fediverse + title: Pubblica elenco dei server scoperti preview_sensitive_media: desc_html: Le anteprime dei link su altri siti mostreranno un thumbnail anche se il media è segnato come sensibile title: Mostra media sensibili nella anteprime OpenGraph + profile_directory: + title: Attiva directory del profilo registrations: closed_message: desc_html: Mostrato nella pagina iniziale quando le registrazioni sono chiuse. Puoi usare tag HTML @@ -382,20 +410,20 @@ it: title: Mostra badge staff site_description: desc_html: Paragrafo introduttivo nella pagina iniziale. Descrive ciò che rende speciale questo server Mastodon e qualunque altra cosa sia importante dire. Potete usare marcatori HTML, in particolare <code><a></code> e <code><em></code>. - title: Descrizione istanza + title: Descrizione del server site_description_extended: - desc_html: Un posto adatto per pubblicare regole di comportamento, linee guida e altre cose specifiche della vostra istanza. Potete usare marcatori HTML + desc_html: Un posto adatto per pubblicare regole di comportamento, linee guida e altre cose specifiche del vostro server. Potete usare marcatori HTML title: Informazioni estese personalizzate site_short_description: - desc_html: Mostrato nella barra laterale e nei tag meta. Descrive in un paragrafo che cos'è Mastodon e che cosa rende questo server speciale. Se vuoto, sarà usata la descrizione predefinita dell'istanza. - title: Breve descrizione dell'istanza + desc_html: Mostrato nella barra laterale e nei tag meta. Descrive in un paragrafo che cos'è Mastodon e che cosa rende questo server speciale. Se vuoto, sarà usata la descrizione predefinita del server. + title: Breve descrizione del server site_terms: desc_html: Potete scrivere la vostra politica sulla privacy, condizioni del servizio o altre informazioni legali. Potete usare tag HTML title: Termini di servizio personalizzati - site_title: Nome istanza + site_title: Nome del server thumbnail: desc_html: Usato per anteprime tramite OpenGraph e API. 1200x630px consigliati - title: Thumbnail dell'istanza + title: Thumbnail del server timeline_preview: desc_html: Mostra la timeline pubblica sulla pagina iniziale title: Anteprima timeline @@ -418,7 +446,18 @@ it: confirmed: Confermato expires_in: Scade in topic: Argomento + tags: + accounts: Account + hidden: Nascosto + name: Hashtag + title: Hashtag + unhide: Mostra nella directory + visible: Visibile title: Amministrazione + warning_presets: + add_new: Aggiungi nuovo + delete: Cancella + edit: Modifica application_mailer: notification_preferences: Cambia preferenze email salutation: "%{name}," @@ -434,7 +473,7 @@ it: token_regenerated: Token di accesso rigenerato warning: Fa' molta attenzione con questi dati. Non fornirli mai a nessun altro! auth: - agreement_html: Iscrivendoti, accetti di seguire <a href="%{rules_path}">le regole dell'istanza</a> e <a href="%{terms_path}"> le nostre condizioni di servizio</a>. + agreement_html: Iscrivendoti, accetti di seguire <a href="%{rules_path}">le regole del server</a> e <a href="%{terms_path}"> le nostre condizioni di servizio</a>. change_password: Password confirm_email: Conferma email delete_account: Elimina account @@ -484,11 +523,14 @@ it: description_html: Questa azione eliminerà <strong>in modo permanente e irreversibile</strong> tutto il contenuto del tuo account e lo disattiverà. Il tuo nome utente resterà riservato per prevenire che qualcuno in futuro assuma la tua identità. proceed: Cancella l'account success_msg: Il tuo account è stato cancellato - warning_html: È garantita solo la cancellazione del contenuto solo da questa istanza. I contenuti che sono stati ampiamente condivisi probabilmente lasceranno delle tracce. I server offline e quelli che non ricevono più i tuoi aggiornamenti non aggiorneranno i loro database. + warning_html: È garantita la cancellazione del contenuto solo da questo server. I contenuti che sono stati ampiamente condivisi probabilmente lasceranno delle tracce. I server offline e quelli che non ricevono più i tuoi aggiornamenti non aggiorneranno i loro database. + directories: + explanation: Scopri utenti in base ai loro interessi + explore_mastodon: Esplora %{title} errors: '403': Non sei autorizzato a visualizzare questa pagina. '404': La pagina che stavi cercando non esiste. - '410': La pagina che stavi cercando non esiste più. + '410': La pagina che stavi cercando qui non esiste più. '422': content: Verifica di sicurezza non riuscita. Stai bloccando i cookies? title: Verifica di sicurezza non riuscita @@ -506,9 +548,13 @@ it: size: Dimensioni blocks: Stai bloccando csv: CSV + domain_blocks: Blocchi di dominio follows: Stai seguendo + lists: Liste mutes: Stai silenziando storage: Archiviazione media + featured_tags: + add_new: Aggiungi nuovo filters: contexts: home: Timeline home @@ -526,7 +572,7 @@ it: title: Aggiungi filtro followers: domain: Dominio - explanation_html: Se vuoi garantire la privacy dei tuoi status, devi sapere chi ti sta seguendo. <strong>I tuoi status privati vengono inviati a tutte le istanze su cui hai dei seguaci</strong>. Puoi controllare chi sono i tuoi seguaci, ed eliminarli se non hai fiducia che la tua privacy venga rispettata dallo staff o dal software di quelle istanze. + explanation_html: Se vuoi garantire la privacy dei tuoi status, devi sapere chi ti sta seguendo. <strong>I tuoi status privati vengono inviati a tutti i server su cui hai dei seguaci</strong>. Puoi controllare chi sono i tuoi seguaci, ed eliminarli se non hai fiducia che la tua privacy venga rispettata dallo staff o dal software di quei server. followers_count: Numero di seguaci lock_link: Blocca il tuo account purge: Elimina dai seguaci @@ -544,7 +590,9 @@ it: one: Qualcosa ancora non va bene! Per favore, controlla l'errore qui sotto other: Qualcosa ancora non va bene! Per favore, controlla i %{count} errori qui sotto imports: - preface: Puoi importare alcune informazioni, come le persone che segui o hai bloccato su questo server, da file creati da un esportazione su un altro server. + modes: + overwrite: Sovrascrivi + preface: Puoi importare alcune informazioni, come le persone che segui o hai bloccato su questo server, da file creati da un'esportazione su un altro server. success: Le tue impostazioni sono state importate correttamente e verranno applicate in breve tempo types: blocking: Lista dei bloccati @@ -569,7 +617,7 @@ it: one: un uso other: "%{count} utilizzi" max_uses_prompt: Nessun limite - prompt: Genera e condividi dei link ad altri per garantire l'accesso a questa istanza + prompt: Genera e condividi dei link con altri per concedere l'accesso a questo server table: expires_at: Scade uses: Utilizzi @@ -735,8 +783,8 @@ it: terms: title: "%{instance} Termini di servizio e politica della privacy" themes: - contrast: Contrasto elevato - default: Mastodon + contrast: Mastodon (contrasto elevato) + default: Mastodon (scuro) mastodon-light: Mastodon (chiaro) time: formats: @@ -768,7 +816,7 @@ it: final_action: Inizia a postare final_step: 'Inizia a postare! Anche se non hai seguaci, i tuoi messaggi pubblici possono essere visti da altri, ad esempio nelle timeline locali e negli hashtag. Se vuoi puoi presentarti con l''hashtag #introductions.' full_handle: Il tuo nome utente completo - full_handle_hint: Questo è ciò che diresti ai tuoi amici in modo che possano seguirti o contattarti da un'altra istanza. + full_handle_hint: Questo è ciò che diresti ai tuoi amici in modo che possano seguirti o contattarti da un altro server. review_preferences_action: Cambia preferenze review_preferences_step: Dovresti impostare le tue preferenze, ad esempio quali email vuoi ricevere oppure il livello predefinito di privacy per i tuoi post. Se le immagini in movimento non ti danno fastidio, puoi abilitare l'animazione automatica delle GIF. subject: Benvenuto/a su Mastodon diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 598726c57..5e5b3ae36 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -287,7 +287,7 @@ ja: suspend: このドメインからの存在するすべてのアカウントの停止を戻す title: "%{domain}のドメインブロックを戻す" undo: 元に戻す - undo: 元に戻す + undo: ドメインブロックを戻す email_domain_blocks: add_new: 新規追加 created_msg: ブラックリストに追加しました @@ -302,6 +302,7 @@ ja: back_to_account: 戻る title: "%{acct}さんのフォロワー" instances: + by_domain: ドメイン delivery_available: 配送可能 known_accounts: one: 既知のアカウント数 %{count} diff --git a/config/locales/kk.yml b/config/locales/kk.yml index 97a0626e6..1c40adeb7 100644 --- a/config/locales/kk.yml +++ b/config/locales/kk.yml @@ -33,7 +33,7 @@ kk: status_count_after: one: жазба other: жазба - status_count_before: Жазылғандар + status_count_before: Барлығы terms: Қолдану шарттары user_count_after: one: қолданушы @@ -104,6 +104,7 @@ kk: email_status: Email статусы enable: Қосу enabled: Қосылды + feed_url: Feеd URL followers: Оқырмандар followers_url: Оқырмандар URL follows: Жазылғандары @@ -147,6 +148,7 @@ kk: success: Құптау хаты сәтті жіберілді! reset: Қалпына келтіру reset_password: Құпиясөзді қалпына келтіру + resubscribe: Resubscribе role: Қайта жазылу roles: admin: Админ @@ -262,6 +264,7 @@ kk: create: Блок құру hint: Домендік блок дерекқорда тіркелгі жазбаларын құруға кедергі жасамайды, бірақ сол есептік жазбаларда ретроактивті және автоматты түрде нақты модерация әдістерін қолданады. severity: + desc_html: "<strong>Silence</strong> will make the account's posts invisible to anyone who isn't following them. <strong>Suspend</strong> will remove all of the account's content, media, and profile data. Use <strong>None</strong> if you just want to reject media filеs." noop: Ештеңе silence: Үнсіз suspend: Тоқтатылған @@ -289,11 +292,17 @@ kk: add_new: Жаңасын қосу created_msg: Қаратізімге email домені қосылды delete: Өшіру + destroyed_msg: Successfully deletеd e-mail domain from blacklist domain: Домен + new: + create: Add dоmain + title: New e-mail blаcklist entry title: E-mail қаратізімі followers: + back_to_account: Back To Accоunt title: "%{acct} оқырмандары" instances: + by_domain: Domаin delivery_available: Жеткізу қол жетімді known_accounts: one: "%{count} таныс аккаунт" @@ -319,9 +328,11 @@ kk: relays: add_new: Жаңа арна қосу delete: Өшіру + description_html: A <strong>fedеration relay</strong> is an intermediary server that exchanges large volumes of public toots between servers that subscribe and publish to it. <strong>It can help small and medium servers discover content from the fediverse</strong>, which would otherwise require local users manually following other people on remote servers. disable: Сөндіру disabled: Сөндірілді enable: Қосу + enable_hint: Once enabled, your server will subscribe to all public toots from this rеlay, and will begin sending this server's public toots to it. enabled: Қосылды inbox_url: Арна URL pending: Жаңа арна құпталуын күту @@ -432,6 +443,7 @@ kk: statuses: back_to_account: Аккаунт бетіне оралы batch: + delete: Delеte nsfw_off: Сезімтал емес ретінде белгіле nsfw_on: Сезімтал ретінде белгіле failed_to_execute: Орындалмады @@ -443,3 +455,559 @@ kk: with_media: Медиамен subscriptions: callback_url: Callbаck URL + confirmed: Confirmеd + expires_in: Expirеs in + last_delivery: Last dеlivery + title: WеbSub + topic: Tоpic + tags: + accounts: Accоunts + hidden: Hiddеn + hide: Hidе from directory + name: Hаshtag + title: Hashtаgs + unhide: Shоw in directory + visible: Visiblе + title: Administrаtion + warning_presets: + add_new: Add nеw + delete: Deletе + edit: Еdit + edit_preset: Edit warning prеset + title: Manage warning presеts + admin_mailer: + new_report: + body: "%{reporter} has rеported %{target}" + body_remote: Someone from %{domain} has rеported %{target} + subject: New rеport for %{instance} (#%{id}) + application_mailer: + notification_preferences: Change e-mail prеferences + salutation: "%{name}," + settings: 'Change e-mail preferеnces: %{link}' + view: 'Viеw:' + view_profile: Viеw Profile + view_status: Viеw status + applications: + created: Application succеssfully created + destroyed: Application succеssfully deleted + invalid_url: The providеd URL is invalid + regenerate_token: Regenerate accеss token + token_regenerated: Access token succеssfully regenerated + warning: Be very carеful with this data. Never share it with anyone! + your_token: Your access tokеn + auth: + agreement_html: '"Тіркелу" батырмасын басу арқылы <a href="%{rules_path}">сервер ережелері</a> мен <a href="%{terms_path}">қолдану шарттарына</a> келісесіз.' + change_password: Құпиясөз + confirm_email: Еmаil құптау + delete_account: Аккаунт өшіру + delete_account_html: Аккаунтыңызды жойғыңыз келсе, <a href="%{path}">мына жерді</a> басыңыз. Сізден растау сұралатын болады. + didnt_get_confirmation: Растау хаты келмеді ме? + forgot_password: Құпиясөзіңізді ұмытып қалдыңыз ба? + invalid_reset_password_token: Құпиясөз қайтып алу қолжетімді емес немесе мерзімі аяқталған. Қайтадан сұратыңыз. + login: Кіру + logout: Шығу + migrate_account: Басқа аккаунтқа көшіру + migrate_account_html: Егер аккаунтыңызды басқасына байлағыңыз келсе, <a href="%{path}">мына жерге келіңіз</a>. + or: немесе + or_log_in_with: Немесе былай кіріңіз + providers: + cas: САS + saml: SАML + register: Тіркелу + register_elsewhere: Басқа серверге тіркелу + resend_confirmation: Растау нұсқаулықтарын жіберу + reset_password: Құпиясөзді қалпына келтіру + security: Қауіпсіздік + set_new_password: Жаңа құпиясөз қою + authorize_follow: + already_following: Бұл аккаунтқа жазылғансыз + error: Өкінішке орай, қашықтағы тіркелгіні іздеуде қате пайда болды + follow: Жазылу + follow_request: 'Сіз жазылуға өтініш жібердіңіз:' + following: 'Керемет! Сіз енді жазылдыңыз:' + post_follow: + close: Немесе терезені жаба салыңыз. + return: Қолданушы профилін көрсет + web: Вебте ашу + title: Жазылу %{acct} + datetime: + distance_in_words: + about_x_hours: "%{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}күн" + x_minutes: "%{count}мин" + x_months: "%{count}ай" + x_seconds: "%{count}сек" + deletes: + bad_password_msg: Болмады ма, хакер бала? Құпиясөз қате + confirm_password: Қазіргі құпиясөзіңізді жазыңыз + description_html: This will <strong>permanently, irreversibly</strong> remove content from your account аnd deactivate it. Your username will remain reserved to prevent future impersonations. + proceed: Аккаунт өшіру + success_msg: Аккаунтыңыз сәтті өшірілді + warning_html: Only deletion of content from this particular server is guaranteed. Content that has been widely sharеd is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases. + warning_title: Бөлінген мазмұнның қол жетімділігі + directories: + directory: Профильдер каталогы + enabled: Каталогтағы тізімге ендіңіз. + enabled_but_waiting: Каталогта көрінгіңіз келетінін түсінеміз, бірақ ол үшін кем дегенде (%{min_followers}) оқырманыңыз болуы қажет. + explanation: Қолданушыларды қызығушылықтарына қарай реттеу + explore_mastodon: "%{title} шарлау" + how_to_enable: Сіз қазіргі уақытта каталогқа қосылмағансыз. Төменде қосылуға болады. Арнайы био мәтініндегі хэштегтерді қолданыңыз! + people: + one: "%{count} адам" + other: "%{count} адам" + errors: + '403': Бұны көру үшін сізде рұқсат жоқ. + '404': Сіз іздеген бет бұл жерде емес екен. + '410': Сіз іздеген бет қазір жоқ екен. + '422': + content: Қауіпсіздік растауы қате. кукилерді блоктағансыз ба? + title: Қауіпсіздік растауы жасалмады + '429': Қысқартылған + '500': + content: Кешірерсіз, бірақ қазір бір қате пайда болып тұр. + title: Бұл бет дұрыс емес екен + noscript_html: Mastodon веб қосымшасын қолдану үшін, JavaScript қосыңыз. Болмай жатса, <a href="%{apps_path}">мына қосымшаларды</a> қосып көріңіз, Mastodon қолдану үшін. + exports: + archive_takeout: + date: Уақыты + download: Мұрағатыңызды түсіріп алыңыз + hint_html: Өзіңіздің <strong>жазба және медиаларыңыздың</strong> мұрағатын сақтап алуыңызға болады. Экспортталатын деректер ActivityPub форматында болады, сәйкес бағдарламамлармен ашуға болады. Әр 7 күн сайын сұратуыңызға болады. + in_progress: Мұрағатыңызды жинақтау... + request: Мұрағат сұрату + size: Өлшемі + blocks: Бұғатталғансыз + csv: СSV + domain_blocks: Домен блоктары + follows: Оқитындарыңыз + lists: Тізімдер + mutes: Үнсіздер + storage: Медиа жинақ + featured_tags: + add_new: Жаңасын қосу + errors: + limit: Хэштег лимитинен асып кеттіңіз + filters: + contexts: + home: Ішкі желі + notifications: Ескертпелер + public: Ашық желі + thread: Пікірталас + edit: + title: Фильтр өңдеу + errors: + invalid_context: Жоқ немесе жарамсыз контекст берілген + invalid_irreversible: Қайтарылмайтын сүзгі тек ішкі немесе ескертпелер контекстімен жұмыс істейді + index: + delete: Өшіру + title: Фильтрлер + new: + title: Жаңа фильтр қосу + followers: + domain: Домен + explanation_html: Егер сіз жазбаларыңыздың құпиялылығын қамтамасыз еткіңіз келсе, сізді кім іздейтінін білуіңіз керек. <strong> Сіздің жазбаларыңыз оқырмандарыңыз бар барлық серверлерге жеткізіледі </strong>. Оларды оқырмандарыңызға және админдерге немесе осы серверлердің бағдарламалық жасақтамасына жауапты қызметкерлерге сенбесеңіз, оқырмандарыңызды алып тастауыңызға болады. + followers_count: Оқырман саны + lock_link: Аккаунтыңызды құлыптау + purge: Оқырмандар тізімінен шығару + success: + one: Бір доменнен оқырмандарды бұғаттау барысында... + other: "%{count} доменнен оқырмандарды бұғаттау барысында..." + true_privacy_html: Ұмытпаңыз, <strong>нақты құпиялылықты шифрлаудан соң ғана қол жеткізуге болатындығын ескеріңіз.</strong>. + unlocked_warning_html: Кез келген адам жазбаларыңызды оқу үшін сізге жазыла алады. Жазылушыларды қарап, қабылдамау үшін %{lock_link}. + unlocked_warning_title: Аккаунтыңыз қазір құлыпталды + footer: + developers: Жасаушылар + more: Тағы… + resources: Ресурстар + generic: + changes_saved_msg: Өзгерістер сәтті сақталды! + copy: Көшіру + save_changes: Өзгерістерді сақтау + validation_errors: + one: Бір нәрсе дұрыс емес! Төмендегі қатені қараңыз + other: Бір нәрсе дұрыс емес! Төмендегі %{count} қатені қараңыз + imports: + modes: + merge: Біріктіру + merge_long: Бар жазбаларды сақтаңыз және жаңаларын қосыңыз + overwrite: Үстіне жазу + overwrite_long: Ағымдағы жазбаларды жаңаларына ауыстырыңыз + preface: Басқа серверден экспортталған деректерді импорттауға болады, мысалы, сіз бақылайтын немесе блоктайтын адамдардың тізімін. + success: Деректеріңіз сәтті жүктелді және дер кезінде өңделеді + types: + blocking: Бұғат тізімі + domain_blocking: Домен бұғаттары тізімі + following: Жазылғандар тізімі + muting: Үнсіздер тізімі + upload: Жүктеу + in_memoriam_html: Естеліктерде. + invites: + delete: Ажырату + expired: Мерзімі өткен + expires_in: + '1800': 30 минут + '21600': 6 сағат + '3600': 1 сағат + '43200': 12 сағат + '604800': 1 апта + '86400': 1 күн + expires_in_prompt: Ешқашан + generate: Құру + invited_by: 'Сізді шақырған:' + max_uses: + one: 1 қолданыс + other: "%{count} қолданыс" + max_uses_prompt: Лимитсіз + prompt: Осы серверге кіру рұқсатын беру үшін сілтемелерді жасаңыз және бөлісіңіз + table: + expires_at: Аяқталу мерзімі + uses: Қолданыс + title: Адам шақыру + lists: + errors: + limit: Сіз тізімдердің максимум мөлшеріне жеттіңіз + media_attachments: + validations: + images_and_video: Жазбаға видео қоса алмайсыз, тек сурет қосуға болады + too_many: 4 файлдан артық қосылмайды + migrations: + acct: жаңа аккаунт үшін username@domain + currently_redirecting: 'Профиліңіз көшіріледі:' + proceed: Сақтау + updated_msg: Аккаунт көшіруіңіз сәтті аяқталды! + moderation: + title: Модерация + notification_mailer: + digest: + action: Барлық ескертпелер + body: Міне, соңғы кірген уақыттан кейін келген хаттардың қысқаша мазмұны %{since} + mention: "%{name} сізді атап өтіпті:" + new_followers_summary: + one: Сондай-ақ, сіз бір жаңа оқырман таптыңыз! Алақай! + other: Сондай-ақ, сіз %{count} жаңа оқырман таптыңыз! Керемет! + subject: + one: "Соңғы кіруіңізден кейін 1 ескертпе келіпті \U0001F418" + other: "Соңғы кіруіңізден кейін %{count} ескертпе келіпті \U0001F418" + title: Сіз жоқ кезде... + favourite: + body: 'Жазбаңызды ұнатып, таңдаулыға қосты %{name}:' + subject: "%{name} жазбаңызды таңдаулыға қосты" + title: Жаңа таңдаулы + follow: + body: "%{name} сізге жазылды!" + subject: "%{name} сізге жазылды" + title: Жаңа оқырман + follow_request: + action: Жазылуға сұранымдарды реттеу + body: "%{name} сізге жазылғысы келеді" + subject: 'Жазылғысы келеді: %{name}' + title: Жазылуға сұраным + mention: + action: Жауап + body: 'Сізді атап өтіпті %{name} мында:' + subject: Сізді %{name} атап өтіпті + title: Жаңа аталым + reblog: + body: 'Жазбаңызды бөліскен %{name}:' + subject: "%{name} жазбаңызды бөлісті" + title: Жаңа бөлісім + number: + human: + decimal_units: + format: "%n%u" + units: + billion: В + million: М + quadrillion: Q + thousand: К + trillion: Т + pagination: + newer: Ешқашан + next: Келесі + older: Ерте + prev: Алдыңғы + truncate: "…" + preferences: + languages: Тілдер + other: Басқа + publishing: Жариялау + web: Веб + remote_follow: + acct: Өзіңіздің username@domain теріңіз + missing_resource: Аккаунтыңызға байланған URL табылмады + no_account_html: Әлі тіркелмегенсіз бе? Мына жерден <a href='%{sign_up_path}' target='_blank'>тіркеліп алыңыз</a> + proceed: Жазылу + prompt: 'Жазылғыңыз келеді:' + reason_html: "<strong>Неліктен бұл қадам қажет?</strong> <code>%{instance}</code> тіркелгіңіз келген сервер болмауы мүмкін, сондықтан сізді алдымен ішкі серверіңізге қайта бағыттау қажет." + remote_interaction: + favourite: + proceed: Таңдаулыға қосу + prompt: 'Мына жазбаны таңдаулыға қосасыз:' + reblog: + proceed: Жазба бөлісу + prompt: 'Сіз мына жазбаны бөлісесіз:' + reply: + proceed: Жауап жазу + prompt: 'Сіз мына жазбаға жауап жазасыз:' + remote_unfollow: + error: Қате + title: Тақырыбы + unfollowed: Жазылудан бас тартылды + scheduled_statuses: + over_daily_limit: Сіз бір күндік %{limit} жазба лимитін тауыстыңыз + over_total_limit: Сіз %{limit} жазба лимитін тауыстыңыз + too_soon: Жоспарланған күн болашақта болуы керек + sessions: + activity: Соңғы әрекеттер + browser: Браузер + browsers: + alipay: Аlipay + blackberry: Blаckberry + chrome: Chrоme + edge: Microsоft Edge + electron: Electrоn + firefox: Firеfox + generic: Белгісіз браузер + ie: Internet Explоrer + micro_messenger: MicroMеssenger + nokia: Nokia S40 Ovi Brоwser + opera: Opеra + otter: Ottеr + phantom_js: PhаntomJS + qq: QQ Brоwser + safari: Safаri + uc_browser: UCBrоwser + weibo: Weibо + current_session: Қазіргі сессия + description: "%{browser} - %{platform}" + explanation: Сіздің аккаунтыңызбен кірілген браузерлер тізімі. + ip: ІР + platforms: + adobe_air: Adobе Air + android: Andrоid + blackberry: Blackbеrry + chrome_os: ChromеOS + firefox_os: Firefоx OS + ios: iОS + linux: Lіnux + mac: Mаc + other: белгісіз платформа + windows: Windоws + windows_mobile: Windows Mоbile + windows_phone: Windоws Phone + revoke: Шығып кету + revoke_success: Сессиялар сәтті жабылды + title: Сессиялар + settings: + authorized_apps: Authorizеd apps + back: Желіге оралу + delete: Аккаунт өшіру + development: Жасаушы топ + edit_profile: Профиль өңдеу + export: Экспорт уақыты + featured_tags: Таңдаулы хэштегтер + followers: Авторизацияланған оқырмандар + import: Импорт + migrate: Аккаунт көшіру + notifications: Ескертпелер + preferences: Таңдаулар + settings: Баптаулар + two_factor_authentication: Екі-факторлы авторизация + your_apps: Қосымшалар + statuses: + attached: + description: 'Жүктелді: %{attached}' + image: + one: "%{count} сурет" + other: "%{count} сурет" + video: + one: "%{count} видео" + other: "%{count} видео" + boosted_from_html: Бөлісілді %{acct_link} + content_warning: 'Контент ескертуі: %{warning}' + disallowed_hashtags: + one: 'рұқсат етілмеген хэштег: %{tags}' + other: 'рұқсат етілмеген хэштегтер: %{tags}' + language_detection: Тілді өздігінен таңда + open_in_web: Вебте ашу + over_character_limit: "%{max} максимум таңбадан асып кетті" + pin_errors: + limit: Жабыстырылатын жазба саны максимумынан асты + ownership: Біреудің жазбасы жабыстырылмайды + private: Жабық жазба жабыстырылмайды + reblog: Бөлісілген жазба жабыстырылмайды + show_more: Тағы әкел + sign_in_to_participate: Сұхбатқа қатысу үшін кіріңіз + title: '%{name}: "%{quote}"' + visibilities: + private: Тек оқырмандарға + private_long: Тек оқырмандарға ғана көрінеді + public: Ашық + public_long: Бәрі көре алады + unlisted: Тізімге енбеген + unlisted_long: Бәрі көре алады, бірақ ашық тізімдерге ене алмайды + stream_entries: + pinned: Жабыстырылған жазба + reblogged: бөлісті + sensitive_content: Нәзік мазмұн + terms: + body_html: | + <h2>Құпиялылық шарттары</h2> + <h3 id="collect">What information do we collect?</h3> + + <ul> + <li><em>Basic account information</em>: If you register on this server, you may be asked to enter a username, an e-mail address and a password. You may also enter additional profile information such as a display name and biography, and upload a profile picture and header image. The username, display name, biography, profile picture and header image are always listed publicly.</li> + <li><em>Posts, following and other public information</em>: The list of people you follow is listed publicly, the same is true for your followers. When you submit a message, the date and time is stored as well as the application you submitted the message from. Messages may contain media attachments, such as pictures and videos. Public and unlisted posts are available publicly. When you feature a post on your profile, that is also publicly available information. Your posts are delivered to your followers, in some cases it means they are delivered to different servers and copies are stored there. When you delete posts, this is likewise delivered to your followers. The action of reblogging or favouriting another post is always public.</li> + <li><em>Direct and followers-only posts</em>: All posts are stored and processed on the server. Followers-only posts are delivered to your followers and users who are mentioned in them, and direct posts are delivered only to users mentioned in them. In some cases it means they are delivered to different servers and copies are stored there. We make a good faith effort to limit the access to those posts only to authorized persons, but other servers may fail to do so. Therefore it's important to review servers your followers belong to. You may toggle an option to approve and reject new followers manually in the settings. <em>Please keep in mind that the operators of the server and any receiving server may view such messages</em>, and that recipients may screenshot, copy or otherwise re-share them. <em>Do not share any dangerous information over Mastodon.</em></li> + <li><em>IPs and other metadata</em>: When you log in, we record the IP address you log in from, as well as the name of your browser application. All the logged in sessions are available for your review and revocation in the settings. The latest IP address used is stored for up to 12 months. We also may retain server logs which include the IP address of every request to our server.</li> + </ul> + + <hr class="spacer" /> + + <h3 id="use">What do we use your information for?</h3> + + <p>Any of the information we collect from you may be used in the following ways:</p> + + <ul> + <li>To provide the core functionality of Mastodon. You can only interact with other people's content and post your own content when you are logged in. For example, you may follow other people to view their combined posts in your own personalized home timeline.</li> + <li>To aid moderation of the community, for example comparing your IP address with other known ones to determine ban evasion or other violations.</li> + <li>The email address you provide may be used to send you information, notifications about other people interacting with your content or sending you messages, and to respond to inquiries, and/or other requests or questions.</li> + </ul> + + <hr class="spacer" /> + + <h3 id="protect">How do we protect your information?</h3> + + <p>We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information. Among other things, your browser session, as well as the traffic between your applications and the API, are secured with SSL, and your password is hashed using a strong one-way algorithm. You may enable two-factor authentication to further secure access to your account.</p> + + <hr class="spacer" /> + + <h3 id="data-retention">What is our data retention policy?</h3> + + <p>We will make a good faith effort to:</p> + + <ul> + <li>Retain server logs containing the IP address of all requests to this server, in so far as such logs are kept, no more than 90 days.</li> + <li>Retain the IP addresses associated with registered users no more than 12 months.</li> + </ul> + + <p>You can request and download an archive of your content, including your posts, media attachments, profile picture, and header image.</p> + + <p>You may irreversibly delete your account at any time.</p> + + <hr class="spacer"/> + + <h3 id="cookies">Do we use cookies?</h3> + + <p>Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account.</p> + + <p>We use cookies to understand and save your preferences for future visits.</p> + + <hr class="spacer" /> + + <h3 id="disclose">Do we disclose any information to outside parties?</h3> + + <p>We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety.</p> + + <p>Your public content may be downloaded by other servers in the network. Your public and followers-only posts are delivered to the servers where your followers reside, and direct messages are delivered to the servers of the recipients, in so far as those followers or recipients reside on a different server than this.</p> + + <p>When you authorize an application to use your account, depending on the scope of permissions you approve, it may access your public profile information, your following list, your followers, your lists, all your posts, and your favourites. Applications can never access your e-mail address or password.</p> + + <hr class="spacer" /> + + <h3 id="children">Site usage by children</h3> + + <p>If this server is in the EU or the EEA: Our site, products and services are all directed to people who are at least 16 years old. If you are under the age of 16, per the requirements of the GDPR (<a href="https://en.wikipedia.org/wiki/General_Data_Protection_Regulation">General Data Protection Regulation</a>) do not use this site.</p> + + <p>If this server is in the USA: Our site, products and services are all directed to people who are at least 13 years old. If you are under the age of 13, per the requirements of COPPA (<a href="https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Children's Online Privacy Protection Act</a>) do not use this site.</p> + + <p>Law requirements can be different if this server is in another jurisdiction.</p> + + <hr class="spacer" /> + + <h3 id="changes">Changes to our Privacy Policy</h3> + + <p>If we decide to change our privacy policy, we will post those changes on this page.</p> + + <p>This document is CC-BY-SA. It was last updated March 7, 2018.</p> + + <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p> + title: "%{instance} Қызмет көрсету шарттары және Құпиялылық саясаты" + themes: + contrast: Mastodon (Жоғары контраст) + default: Mastodon (Қою) + mastodon-light: Mastodon (Ашық) + time: + formats: + default: "%b %d, %Y, %H:%M" + month: "%b %Y" + two_factor_authentication: + code_hint: Растау үшін түпнұсқалықты растау бағдарламасы арқылы жасалған кодты енгізіңіз + description_html: "<strong>екі факторлы түпнұсқалықты растауды</strong> қоссаңыз, кіру үшін сізге телефонға кіруіңізді талап етеді, сізге арнайы токен беріледі." + disable: Ажырату + enable: Қосу + enabled: Екі-факторлы авторизация қосылған + enabled_success: Екі-факторлы авторизация сәтті қосылды + generate_recovery_codes: Қалпына келтіру кодтарын жасаңыз + instructions_html: "<strong>Мына QR кодты Google Authenticator арқылы скандаңыз немесе ұқсас TOTP бағдарламалары арқылы</strong>. Одан кейін желіге кіру үшін токендер берілетін болады." + lost_recovery_codes: Қалпына келтіру кодтары телефонды жоғалтсаңыз, тіркелгіңізге қайта кіруге мүмкіндік береді. Қалпына келтіру кодтарын жоғалтсаңыз, оларды осында қалпына келтіре аласыз. Ескі қалпына келтіру кодтары жарамсыз болады. + manual_instructions: 'Егер сіз QR-кодты сканерлей алмасаңыз және оны қолмен енгізуіңіз қажет болса, мұнда қарапайым нұсқаулық:' + recovery_codes: Қалпына келтіру кодтарын резервтік көшіру + recovery_codes_regenerated: Қалпына келтіру кодтары қалпына келтірілді + recovery_instructions_html: Егер сіз телефонға кіруді жоғалтсаңыз, тіркелгіңізге кіру үшін төмендегі қалпына келтіру кодтарының бірін пайдалануға болады. <strong>Қалпына келтіру кодтарын қауіпсіз ұстаңыз </strong>. Мысалы, оларды басып шығарып, оларды басқа маңызды құжаттармен сақтауға болады. + setup: Орнату + wrong_code: Енгізілген код жарамсыз! Сервер уақыты мен құрылғының уақыты дұрыс па? + user_mailer: + backup_ready: + explanation: Сіз Mastodon аккаунтыңыздың толық мұрағатын сұрадыңыз. Қазір жүктеуге дайын! + subject: Мұрағатыңыз түсіріп алуға дайын + title: Мұрағатты алу + warning: + explanation: + disable: Аккаунтыңыз қатып қалса, сіздің деректеріңіз өзгеріссіз қалады, бірақ ол құлыптан босатылғанша ешқандай әрекетті орындай алмайсыз. + silence: While your account is limited, only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follоw you. + suspend: Сіздің аккаунтыңыз уақытша тоқтатылды және сіздің барлық файлдарыңыз бен жүктеп салынған медиа файлдарыңыз осы серверлерден және оқырманы болған серверлерден қайтарылмайды. + review_server_policies: Сервер саясатын қарап шығыңыз + subject: + disable: Аккаунтыңыз %{acct} уақытша тоқтатылды + none: "%{acct} ескертуі" + silence: "%{acct} аккаунтыңыз шектеулі" + suspend: "%{acct} аккаунт тоқтатылды" + title: + disable: Аккаунт қатырылды + none: Ескерту + silence: Аккаунт шектеулі + suspend: Аккаунт тоқтатылды + welcome: + edit_profile_action: Профиль өңдеу + edit_profile_step: Профиліңізге аватар, мұқаба сурет жүктей аласыз, аты-жөніңізді көрсете аласыз. Оқырмандарыңызға сізбен танысуға рұқсат бермес бұрын аккаунтыңызды уақытша құлыптап қоюға болады. + explanation: Мына кеңестерді шолып өтіңіз + final_action: Жазба жазу + final_step: 'Жазуды бастаңыз! Тіпті оқырмандарыңыз болмаса да, сіздің жалпы жазбаларыңызды басқа адамдар көре алады, мысалы, жергілікті желіде және хэштегтерде. Жазбаларыңызға # протоколды хэштег қоссаңыз болады.' + full_handle: Желі тұтқасы + full_handle_hint: This is what you would tell your friends so they can message or follow you frоm another server. + review_preferences_action: Таңдауларды өзгерту + review_preferences_step: Қандай хат-хабарларын алуды қалайтыныңызды немесе сіздің хабарламаларыңыздың қандай құпиялылық деңгейін алғыңыз келетінін анықтаңыз. Сондай-ақ, сіз GIF автоматты түрде ойнату мүмкіндігін қосуды таңдай аласыз. + subject: Mastodon Желісіне қош келдіңіз + tip_federated_timeline: Жаһандық желі - Mastodon желісінің негізгі құндылығы. + tip_following: Сіз бірден желі админіне жазылған болып саналасыз. Басқа адамдарға жазылу үшін жергілікті және жаһандық желіні шолып шығыңыз. + tip_local_timeline: Жерігілкті желіде маңайыздағы адамдардың белсенділігін көре аласыз %{instance}. Олар - негізгі көршілеріңіз! + tip_mobile_webapp: Мобиль браузеріңіз Mastodon желісін бастапқы бетке қосуды ұсынса, қабылдаңыз. Ескертпелер де шығатын болады. Арнайы қосымша сияқты бұл! + tips: Кеңестер + title: Ортаға қош келдің, %{name}! + users: + follow_limit_reached: Сіз %{limit} лимитінен көп адамға жазыла алмайсыз + invalid_email: Бұл e-mail адрес қате + invalid_otp_token: Қате екі-факторлы код + otp_lost_help_html: Егер кіру жолдарын жоғалтып алсаңыз, сізге %{email} арқылы жіберіледі + seamless_external_login: Сыртқы сервис арқылы кіріпсіз, сондықтан құпиясөз және электрондық пошта параметрлері қол жетімді емес. + signed_in_as: 'Былай кірдіңіз:' + verification: + explanation_html: 'Өзіңіздің профиль метадеректеріңіздегі сілтемелердің иесі ретінде өзіңізді <strong>тексере аласыз</strong>. Ол үшін байланыстырылған веб-сайтта Mastodon профиліне <strong>сілтеме болуы керек. </strong> Сілтемеде <code>rel = «me»</code> атрибуты болуы керек. Сілтеме мәтінінің мазмұны маңызды емес. Міне мысал:' + verification: Растау diff --git a/config/locales/ko.yml b/config/locales/ko.yml index ecee8374c..fe347a703 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -304,6 +304,7 @@ ko: back_to_account: 계정으로 돌아가기 title: "%{acct}의 팔로워" instances: + by_domain: 도메인 delivery_available: 전송 가능 known_accounts: one: 알려진 계정 %{count}개 @@ -735,6 +736,16 @@ ko: older: 오래된 툿 prev: 이전 truncate: "…" + polls: + errors: + already_voted: 이미 투표에 참여하셨습니다 + duplicate_options: 중복된 항목이 있습니다 + duration_too_long: 너무 먼 미래입니다 + duration_too_short: 너무 가깝습니다 + expired: 투표가 이미 끝났습니다 + over_character_limit: 각각 %{max} 글자를 넘을 수 없습니다 + too_few_options: 한가지 이상의 항목을 포함해야 합니다 + too_many_options: 항목은 %{max}개를 넘을 수 없습니다 preferences: languages: 언어 other: 기타 diff --git a/config/locales/lt.yml b/config/locales/lt.yml index ad10c7067..fa3469b11 100644 --- a/config/locales/lt.yml +++ b/config/locales/lt.yml @@ -309,6 +309,7 @@ lt: back_to_account: Atgal Į Paskyrą title: "%{acct} Sekėjai" instances: + by_domain: Domenas delivery_available: Pristatymas galimas known_accounts: few: "%{count} žinomos paskyros" diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 2ba99463b..70094f764 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -302,6 +302,7 @@ nl: back_to_account: Terug naar account title: Volgers van %{acct} instances: + by_domain: Domein delivery_available: Bezorging is mogelijk known_accounts: one: "%{count} bekend account" diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 96848d0f3..a0c077b89 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -302,6 +302,7 @@ oc: back_to_account: Tornar al compte title: Seguidors de %{acct} instances: + by_domain: Domeni delivery_available: Liurason disponibla known_accounts: one: "%{count} compte conegut" @@ -644,6 +645,10 @@ oc: lists: Listas mutes: Personas rescondudas storage: Mèdias gardats + featured_tags: + add_new: Ajustar una etiqueta nòva + errors: + limit: Avètz ja utilizat lo maximum d’etiquetas filters: contexts: home: Flux d’acuèlh @@ -684,10 +689,16 @@ oc: 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: + modes: + merge: Fondre + merge_long: Gardar los enregistraments existents e ajustar los nòus + overwrite: Remplaçar + overwrite_long: Remplaçar los enregistraments actuals pels nòus preface: Podètz importar qualques donadas coma lo monde 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 types: blocking: Lista de blocatge + domain_blocking: Lista dels domenis blocats following: Lista de monde que seguètz muting: Lista de monde que volètz pas legir upload: Importar @@ -713,7 +724,7 @@ oc: table: expires_at: Expirats uses: Usatges - title: Convidar de mond + title: Convidar de monde lists: errors: limit: Avètz atengut lo maximum de listas @@ -856,9 +867,10 @@ oc: delete: Supression de compte development: Desvolopament edit_profile: Modificar lo perfil - export: Export donadas + export: Exportar de donadas + featured_tags: Etiquetas en avant followers: Seguidors autorizats - import: Importar + import: Importar de donadas migrate: Migracion de compte notifications: Notificacions preferences: Preferéncias diff --git a/config/locales/simple_form.ar.yml b/config/locales/simple_form.ar.yml index 14fcfdbd9..f0d121135 100644 --- a/config/locales/simple_form.ar.yml +++ b/config/locales/simple_form.ar.yml @@ -109,7 +109,7 @@ ar: username_or_email: إسم المستخدم أو كلمة السر whole_word: الكلمة كاملة featured_tag: - name: وسم + name: الوسم interactions: must_be_follower: حظر الإخطارات القادمة من حسابات لا تتبعك must_be_following: حظر الإخطارات القادمة من الحسابات التي لا تتابعها diff --git a/config/locales/simple_form.cs.yml b/config/locales/simple_form.cs.yml index 2107530b5..c67a9bd2c 100644 --- a/config/locales/simple_form.cs.yml +++ b/config/locales/simple_form.cs.yml @@ -25,14 +25,14 @@ cs: locked: Vyžaduje, abyste ručně schvaloval/a sledující password: Použijte alespoň 8 znaků phrase: Shoda bude nalezena bez ohledu na velikost písmen v těle tootu či varování o obsahu - scopes: Které API bude aplikace povolena používat. Pokud vyberete rozsah nejvyššího stupně, nebudete je muset vybírat po jednom. + scopes: Která API bude aplikaci povoleno používat. Pokud vyberete rozsah nejvyššího stupně, nebudete je muset vybírat jednotlivě. setting_aggregate_reblogs: Nezobrazovat nové boosty pro tooty, které byly nedávno boostnuty (ovlivňuje pouze nově přijaté boosty) setting_default_language: Jazyk vašich tootů může být detekován automaticky, není to však vždy přesné setting_display_media_default: Skrývat média označená jako citlivá setting_display_media_hide_all: Vždy skrývat všechna média setting_display_media_show_all: Vždy zobrazovat média označená jako citlivá setting_hide_network: Koho sledujete a kdo sleduje vás nebude zobrazeno na vašem profilu - setting_noindex: Ovlivňuje váš veřejný profil a stránky příspěvků + setting_noindex: Ovlivňuje váš veřejný profil a stránky tootů setting_show_application: Aplikace, kterou používáte psaní tootů, bude zobrazena v detailním zobrazení vašich tootů setting_theme: Ovlivňuje jak Mastodon vypadá, jste-li přihlášen na libovolném zařízení. username: Vaše uživatelské jméno bude na %{domain} unikátní @@ -120,11 +120,11 @@ cs: must_be_following_dm: Blokovat přímé zprávy od lidí, které nesledujete notification_emails: digest: Posílat e-maily s přehledem - favourite: Posílat e-maily, když si někdo oblíbí váš příspěvek + favourite: Posílat e-maily, když si někdo oblíbí váš toot follow: Posílat e-maily, když vás někdo začne sledovat follow_request: Posílat e-maily, když vás někdo požádá o sledování mention: Posílat e-maily, když vás někdo zmíní - reblog: Posílat e-maily, když někdo boostne váš příspěvek + reblog: Posílat e-maily, když někdo boostne váš toot report: Posílat e-maily, je-li odesláno nové nahlášení 'no': Ne required: diff --git a/config/locales/simple_form.eo.yml b/config/locales/simple_form.eo.yml index 6cffcc0d1..f3592d584 100644 --- a/config/locales/simple_form.eo.yml +++ b/config/locales/simple_form.eo.yml @@ -6,6 +6,9 @@ eo: text: Vi povas uzi skribmanierojn de mesaĝoj, kiel URL-ojn, kradvortojn kaj menciojn admin_account_action: send_email_notification: La uzanto ricevos klarigon pri tio, kio okazis al ties konto + text_html: Malnepra. Vi povas uzi skribmanierojn de mesaĝoj. Vi povas <a href="%{path}">aldoni avertajn antaŭagordojn</a> por ŝpari tempon + type_html: Elektu kion fari kun <strong>%{acct}</strong> + warning_preset_id: Malnepra. Vi povas ankoraŭ aldoni propran tekston al la fino de la antaŭagordo defaults: autofollow: Homoj, kiuj registriĝos per la invito aŭtomate sekvos vin avatar: Formato PNG, GIF aŭ JPG. Ĝis %{size}. Estos malgrandigita al %{dimensions}px @@ -21,17 +24,22 @@ eo: locked: Vi devos aprobi ĉiun peton de sekvado mane password: Uzu almenaŭ 8 signojn phrase: Estos provita senzorge pri la uskleco de teksto aŭ averto pri enhavo de mesaĝo + scopes: Kiujn API-ojn la aplikaĵo permesiĝos atingi. Se vi elektas supran amplekson, vi ne bezonas elekti la individuajn. + setting_aggregate_reblogs: Ne montri novajn diskonigojn de mesaĝoj laste diskonigitaj (nur efikas al novaj diskonigoj) setting_default_language: La lingvo de viaj mesaĝoj povas esti aŭtomate detektitaj, sed tio ne ĉiam ĝustas setting_display_media_default: Kaŝi aŭdovidaĵojn markitajn kiel tiklaj setting_display_media_hide_all: Ĉiam kaŝi ĉiujn aŭdovidaĵojn setting_display_media_show_all: Ĉiam montri aŭdovidaĵojn markitajn kiel tiklaj setting_hide_network: Tiuj, kiujn vi sekvas, kaj tiuj, kiuj sekvas vin ne estos videblaj en via profilo setting_noindex: Influas vian publikan profilon kaj mesaĝajn paĝojn + setting_show_application: La aplikaĵo, kiun vi uzas por afiŝi, estos montrita en la detala vido de viaj mesaĝoj setting_theme: Influas kiel Mastodon aspektas post ensaluto de ajna aparato. username: Via uzantnomo estos unika ĉe %{domain} whole_word: Kiam la vorto aŭ frazo estas nur litera aŭ cifera, ĝi estos uzata nur se ĝi kongruas kun la tuta vorto + featured_tag: + name: 'Vi povus uzi iun el la jenaj:' imports: - data: CSV-dosiero el alia nodo de Mastodon + data: CSV-dosiero el alia Mastodon-servilo sessions: otp: 'Enmetu la kodon de dufaktora aŭtentigo el via telefono aŭ uzu unu el viaj realiraj kodoj:' user: @@ -41,6 +49,18 @@ eo: fields: name: Etikedo value: Enhavo + account_warning_preset: + text: Antaŭagordita teksto + admin_account_action: + send_email_notification: Atentigi la uzanton retpoŝte + text: Propra averto + type: Ago + types: + disable: Malebligi + none: Fari nenion + silence: Silentigi + suspend: Haltigi kaj nemalfereble forigi kontajn datumojn + warning_preset_id: Uzi antaŭagorditan averton defaults: autofollow: Inviti al sekvi vian konton avatar: Profilbildo @@ -66,6 +86,7 @@ eo: otp_attempt: Kodo de dufaktora aŭtentigo password: Pasvorto phrase: Vorto aŭ frazo + setting_aggregate_reblogs: Grupigi diskonigojn en tempolinioj setting_auto_play_gif: Aŭtomate ekigi GIF-ojn setting_boost_modal: Montri fenestron por konfirmi antaŭ ol diskonigi setting_default_language: Publikada lingvo @@ -80,6 +101,7 @@ eo: setting_hide_network: Kaŝi viajn sekvantojn kaj sekvatojn setting_noindex: Ellistiĝi de retserĉila indeksado setting_reduce_motion: Malrapidigi animaciojn + setting_show_application: Publikigi la aplikaĵon uzatan por sendi mesaĝojn setting_system_font_ui: Uzi la dekomencan tiparon de la sistemo setting_theme: Reteja etoso setting_unfollow_modal: Montri fenestron por konfirmi antaŭ ol ĉesi sekvi iun @@ -88,6 +110,8 @@ eo: username: Uzantnomo username_or_email: Uzantnomo aŭ Retadreso whole_word: Tuta vorto + featured_tag: + name: Kradvorto interactions: must_be_follower: Bloki sciigojn de nesekvantoj must_be_following: Bloki sciigojn de homoj, kiujn vi ne sekvas diff --git a/config/locales/simple_form.it.yml b/config/locales/simple_form.it.yml index dd43898d2..9061844fe 100644 --- a/config/locales/simple_form.it.yml +++ b/config/locales/simple_form.it.yml @@ -33,11 +33,14 @@ it: setting_display_media_show_all: Nascondi sempre i media segnati come sensibili setting_hide_network: Chi segui e chi segue te non saranno mostrati sul tuo profilo setting_noindex: Ha effetto sul tuo profilo pubblico e sulle pagine degli status + setting_show_application: L'applicazione che usi per pubblicare i toot sarà mostrata nella vista di dettaglio dei tuoi toot setting_theme: Ha effetto sul modo in cui Mastodon verrà visualizzato quando sarai collegato da qualsiasi dispositivo. username: Il tuo nome utente sarà unico su %{domain} whole_word: Quando la parola chiave o la frase è solo alfanumerica, si applica solo se corrisponde alla parola intera + featured_tag: + name: 'Eccone alcuni che potresti usare:' imports: - data: File CSV esportato da un'altra istanza di Mastodon + data: File CSV esportato da un altro server Mastodon sessions: otp: 'Inserisci il codice a due fattori generato dall''app del tuo telefono o usa uno dei codici di recupero:' user: @@ -100,14 +103,17 @@ it: setting_hide_network: Nascondi la tua rete setting_noindex: Non farti indicizzare dai motori di ricerca setting_reduce_motion: Riduci movimento nelle animazioni + setting_show_application: Rendi pubblica l'applicazione usata per inviare i toot setting_system_font_ui: Usa il carattere predefinito del sistema setting_theme: Tema sito - setting_unfollow_modal: Mostra dialogo di conferma prima di smettere di seguire qualcuno + setting_unfollow_modal: Chiedi conferma prima di smettere di seguire qualcuno severity: Severità type: Tipo importazione username: Nome utente username_or_email: Nome utente o email whole_word: Parola intera + featured_tag: + name: Hashtag interactions: must_be_follower: Blocca notifiche da chi non ti segue must_be_following: Blocca notifiche dalle persone che non segui diff --git a/config/locales/simple_form.oc.yml b/config/locales/simple_form.oc.yml index ac2845335..84633dde4 100644 --- a/config/locales/simple_form.oc.yml +++ b/config/locales/simple_form.oc.yml @@ -103,6 +103,7 @@ oc: setting_hide_network: Amagar vòstre malhum setting_noindex: Èsser pas indexat pels motors de recèrca setting_reduce_motion: Reduire la velocitat de las animacions + setting_show_application: Revelar lo nom de l’aplicacion utilizada per enviar de tuts setting_system_font_ui: Utilizar la polissa del sistèma setting_theme: Tèma del site setting_unfollow_modal: Mostrar una confirmacion abans de quitar de sègre qualqu’un @@ -111,6 +112,8 @@ oc: username: Nom d’utilizaire username_or_email: Nom d’utilizaire o corrièl whole_word: Mot complèt + featured_tag: + name: Etiqueta interactions: must_be_follower: Blocar las notificacions del mond que vos sègon pas must_be_following: Blocar las notificacions del mond que seguètz pas diff --git a/config/locales/simple_form.sk.yml b/config/locales/simple_form.sk.yml index 504f909c2..17be44e67 100644 --- a/config/locales/simple_form.sk.yml +++ b/config/locales/simple_form.sk.yml @@ -26,7 +26,7 @@ sk: password: Zadaj aspoň osem znakov phrase: Zhoda sa nájde nezávisle od toho, či je text napísaný, veľkými, alebo malými písmenami, či už v tele, alebo v hlavičke scopes: Ktoré API budú povolené aplikácii pre prístup. Ak vyberieš vrcholný stupeň, nemusíš už potom vyberať po jednom. - setting_aggregate_reblogs: Neukazuj nové vyzdvihnutia pre príspevky, ktoré už boli len nedávno povýšené (týka sa iba nanovo získaných vyzdvihnutí) + setting_aggregate_reblogs: Nezobrazuj nové vyzdvihnutia pre príspevky, ktoré už boli len nedávno povýšené (týka sa iba nanovo získaných povýšení) setting_default_language: Jazyk tvojích príspevkov môže byť zistený automaticky, ale nieje to vždy presné setting_display_media_default: Skryť médiá označené ako citlivé setting_display_media_hide_all: Vždy ukryť všetky médiá @@ -106,7 +106,7 @@ sk: setting_show_application: Zverejni akú aplikáciu používaš na posielanie príspevkov setting_system_font_ui: Použi základné systémové písmo setting_theme: Vzhľad webu - setting_unfollow_modal: Zobrazuj potvrdzovacie okno pred skončením sledovania iného užívateľa + setting_unfollow_modal: Vyžaduj potvrdenie pred skončením sledovania iného užívateľa severity: Závažnosť type: Typ importu username: Prezývka @@ -115,9 +115,9 @@ sk: featured_tag: name: Haštag interactions: - must_be_follower: Blokovať oznámenia od nesledujúcich - must_be_following: Blokovať oznámenia od ľudí, ktorých nesleduješ - must_be_following_dm: Blokuj súkromné správy od ľudí ktorých nesleduješ + must_be_follower: Blokuj oboznámenia od užívateľov, ktorí ma nenásledujú + must_be_following: Blokuj oboznámenia od ľudí, ktorých nesledujem + must_be_following_dm: Blokuj súkromné správy od ľudí ktorých nesledujem notification_emails: digest: Posielaj súhrnné emaily favourite: Poslať email ak si niekto obľúbi tvoj príspevok diff --git a/config/locales/simple_form.sv.yml b/config/locales/simple_form.sv.yml index 8bc82c609..62d0b3769 100644 --- a/config/locales/simple_form.sv.yml +++ b/config/locales/simple_form.sv.yml @@ -2,15 +2,23 @@ sv: simple_form: hints: + account_warning_preset: + text: Du kan använda inläggssyntax som webbadresser, hashtaggar och omnämnanden + admin_account_action: + send_email_notification: Användaren kommer att få en förklaring av vad som hände med sitt konto + type_html: Välj vad du vill göra med <strong>%{acct}</strong> defaults: autofollow: Användarkonton som skapas genom din inbjudan kommer automatiskt följa dig - avatar: Högst %{size}. Kommer att skalas ner till %{dimensions}px + avatar: PNG, GIF eller JPG. Högst %{size}. Kommer att skalas ner till %{dimensions}px bot: Detta konto utför huvudsakligen automatiserade åtgärder och kanske inte övervakas digest: Skickas endast efter en lång period av inaktivitet och endast om du har fått några personliga meddelanden i din frånvaro + email: Ett konfirmationsmeddelande kommer att skickas till dig via epost fields: Du kan ha upp till 4 objekt visade som en tabell på din profil - header: NG, GIF eller JPG. Högst %{size}. Kommer nedskalas till %{dimensions}px - locale: Användargränssnittets språk, e-post och push aviseringar + header: PNG, GIF eller JPG. Högst %{size}. Kommer att skalas ner till %{dimensions}px + irreversible: Filtrerade inlägg kommer att försvinna oåterkalleligt, även om filter tas bort senare + locale: Användargränssnittets språk, e-post och push-aviseringar locked: Kräver att du manuellt godkänner följare + password: Använd minst 8 tecken setting_default_language: Språket av dina inlägg kan upptäckas automatiskt, men det är inte alltid rätt setting_hide_network: Vem du följer och vilka som följer dig kommer inte att visas på din profilsida setting_noindex: Påverkar din offentliga profil och statussidor diff --git a/config/locales/sk.yml b/config/locales/sk.yml index 296589bec..0800b8f8c 100644 --- a/config/locales/sk.yml +++ b/config/locales/sk.yml @@ -14,7 +14,7 @@ sk: documentation: Dokumentácia extended_description_html: | <h3>Pravidlá</h3> - <p>Žiadne zatiaľ nie sú</p> + <p>Žiadne zatiaľ uvedené nie sú</p> features: humane_approach_body: Poučený z chýb iných sociálnych sietí, Mastodon sa snaží bojovať so zneužívaním siete voľbou etických návrhov. humane_approach_title: Ľudskejší prístup @@ -80,10 +80,10 @@ sk: account_moderation_notes: create: Zanechaj poznámku created_msg: Poznámka moderátora bola úspešne vytvorená! - delete: Zmazať - destroyed_msg: Poznámka moderátora bola úspešne zmazaná! + delete: Vymaž + destroyed_msg: Moderátorska poznámka bola úspešne zmazaná! accounts: - are_you_sure: Si si istý? + are_you_sure: Si si istý/á? avatar: Maskot by_domain: Doména change_email: @@ -93,13 +93,13 @@ sk: new_email: Nový email submit: Zmeň email title: Zmeň email pre %{username} - confirm: Potvrdiť + confirm: Potvrď confirmed: Potvrdený confirming: Potvrdzujúci deleted: Zmazané demote: Degradovať - disable: Zablokovať - disable_two_factor_authentication: Zakázať 2FA + disable: Zablokuj + disable_two_factor_authentication: Zakáž 2FA disabled: Blokovaný display_name: Zobraziť meno domain: Doména @@ -134,10 +134,10 @@ sk: moderation_notes: Moderátorské poznámky most_recent_activity: Posledná aktivita most_recent_ip: Posledná IP - no_limits_imposed: Niesú stanovené žiadné obmedzenia - not_subscribed: Nezaregistrované + no_limits_imposed: Nie sú stanovené žiadné obmedzenia + not_subscribed: Neodoberá outbox_url: URL poslaných - perform_full_suspension: Zablokovať + perform_full_suspension: Vylúč profile_url: URL profilu promote: Povýš protocol: Protokol @@ -147,12 +147,12 @@ sk: remove_avatar: Odstrániť avatár remove_header: Odstráň hlavičku resend_confirmation: - already_confirmed: Tento užívateľ už je potvrdený - send: Znovu odoslať potvrdzovací email + already_confirmed: Tento užívateľ je už potvrdený + send: Odošli potvrdzovací email znovu success: Potvrdzujúci email bol úspešne odoslaný! reset: Resetuj reset_password: Obnov heslo - resubscribe: Znovu odoberať + resubscribe: Znovu odoberaj role: Oprávnenia roles: admin: Administrátor @@ -165,15 +165,15 @@ sk: show: created_reports: Vytvorené hlásenia targeted_reports: Nahlásenia od ostatných - silence: Stíšiť - silenced: Utíšení + silence: Stíš + silenced: Utíšený/é statuses: Príspevky subscribe: Odoberať suspended: Zablokovaní title: Účty unconfirmed_email: Nepotvrdený email undo_silenced: Zrušiť stíšenie - undo_suspension: Zrušiť suspendáciu + undo_suspension: Zruš blokovanie unsubscribe: Prestaň odoberať username: Prezývka warn: Varovať @@ -214,7 +214,7 @@ sk: title: Kontrólny záznam custom_emojis: by_domain: Doména - copied_msg: Lokálna kópia emoji úspešne vytvorená + copied_msg: Miestna kópia emoji bola úspešne vytvorená copy: Kopíruj copy_failed_msg: Nebolo možné vytvoriť miestnu kópiu tohto emoji created_msg: Emoji úspešne vytvorené! @@ -228,10 +228,10 @@ sk: image_hint: PNG do 50KB listed: V zozname new: - title: Pridať nový vlastný emoji - overwrite: Prepísať + title: Pridaj nové, vlastné emoji + overwrite: Prepíš shortcode: Skratka - shortcode_hint: Aspoň 2 znaky, povolené sú alfanumerické alebo podčiarkovník + shortcode_hint: Aspoň 2 znaky, povolené sú alfanumerické, alebo podčiarkovník title: Vlastné emoji unlisted: Nie je na zozname update_failed_msg: Nebolo možné aktualizovať toto emoji @@ -265,16 +265,16 @@ sk: destroyed_msg: Blokovanie domény bolo zrušené domain: Doména new: - create: Blokovať doménu - hint: Blokovanie domény stále dovolí vytvárať nové účty v databáze, ale tieto budú automaticky moderované. + create: Vytvor blokovanie domény + hint: Blokovanie domény stále dovolí vytvárať nové účty v databázi, ale tieto budú spätne automaticky moderované. severity: desc_html: "<strong>Stíšenie</strong> urobí všetky príspevky daného účtu neviditeľné pre všetkých ktorí nenásledujú tento účet. <strong>Suspendácia</strong> zmaže všetky príspevky, médiá a profilové informácie. Použi <strong>Žiadne</strong>, ak chceš iba neprijímať súbory médií." noop: Nič - silence: Stíšiť - suspend: Vylúčiť + silence: Stíš + suspend: Vylúč title: Nové blokovanie domény - reject_media: Odmietať súbory s obrázkami alebo videami - reject_media_hint: Zmaže lokálne uložené súbory médií a odmietne ich sťahovanie v budúcnosti. Irelevantné pre suspendáciu + reject_media: Odmietaj súbory s obrázkami, alebo videami + reject_media_hint: Vymaže miestne uložené súbory médií a odmietne ich sťahovanie v budúcnosti. Nepodstatné pri vylúčení reject_reports: Zamietni hlásenia reject_reports_hint: Ignoruj všetky hlásenia prichádzajúce z tejto domény. Nevplýva na blokovania rejecting_media: odmietanie médiálnych súborov @@ -307,6 +307,7 @@ sk: back_to_account: Späť na účet title: Následovatielia užívateľa %{acct} instances: + by_domain: Doména delivery_available: Je v dosahu doručovania known_accounts: few: "%{count} známe účty" @@ -404,7 +405,7 @@ sk: desc_html: Náhľad odkazov z iných serverov, bude zobrazený aj vtedy, keď sú médiá označené ako senzitívne title: Ukazuj aj chúlostivé médiá v náhľadoch OpenGraph profile_directory: - desc_html: Povoliť užívateľom aby boli nájdení + desc_html: Povoľ užívateľom, aby mohli byť nájdení title: Zapni profilový katalóg registrations: closed_message: @@ -569,7 +570,7 @@ sk: one: "%{count} človek" other: "%{count} ľudia" errors: - '403': Nemáš povolenie na zobrazenie tejto stránky. + '403': Nemáš povolenie pre zobrazenie tejto stránky. '404': Stránka ktorú hľadáš nieje tu. '410': Stránka ktorú si tu hľadal/a sa tu už viac nenachádza. '422': @@ -584,7 +585,7 @@ sk: archive_takeout: date: Dátum download: Stiahni si svoj archív - hint_html: Môžeš si opýtať <strong>archív svojích príspevkov a nahratých médií</strong>. Exportované dáta budú v ActivityPub formáte, čítateľné hociakým kompatibilným softvérom. Archív si je možné vyžiadať každých sedem dní. + hint_html: Môžeš si vyžiadať <strong>archív svojích príspevkov a nahratých médií</strong>. Exportované dáta budú v ActivityPub formáte, čítateľné hociakým kompatibilným softvérom. Archív si je možné vyžiadať každých sedem dní. in_progress: Balím tvoj archív... request: Vyžiadaj si tvoj archív size: Veľkosť @@ -742,8 +743,18 @@ sk: newer: Novšie next: Ďalšie older: Staršie - prev: Predošlé + prev: Predchádzajúce truncate: "…" + polls: + errors: + already_voted: V tejto ankete si už hlasoval/a + duplicate_options: obsahuje opakujúce sa položky + duration_too_long: je príliš ďaleko do budúcnosti + duration_too_short: je príliš skoro + expired: Anketa už skončila + over_character_limit: každá nemôže byť dlhšia ako %{max} znakov + too_few_options: musí mať viac ako jednu položku + too_many_options: nemôže zahŕňať viac ako %{max} položiek preferences: languages: Jazyky other: Ostatné @@ -910,7 +921,7 @@ sk: code_hint: Pre potvrdenie teraz zadaj kód vygenerovaný pomocou tvojej overovacej aplikácie description_html: Ak povolíš <strong> dvoj-faktorové overovanie</strong>, na prihlásenie potom budeš potrebovať svoj telefón, ktorý vygeneruje prístupové kódy, čo musíš zadať. disable: Zakáž - enable: Povoliť + enable: Povoľ enabled: Dvoj-faktorové overovanie je povolené enabled_success: Dvoj-faktorové overovanie bolo úspešne povolené generate_recovery_codes: Vygeneruj zálohové kódy diff --git a/config/routes.rb b/config/routes.rb index 447a22794..09bcf8b12 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,7 @@ Rails.application.routes.draw do member do get :activity get :embed + get :replies end end @@ -373,6 +374,10 @@ Rails.application.routes.draw do resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' end + resources :polls, only: [:create, :show] do + resources :votes, only: :create, controller: 'polls/votes' + end + namespace :push do resource :subscription, only: [:create, :show, :update, :destroy] end diff --git a/db/migrate/20190225031541_create_polls.rb b/db/migrate/20190225031541_create_polls.rb new file mode 100644 index 000000000..ea9ad0425 --- /dev/null +++ b/db/migrate/20190225031541_create_polls.rb @@ -0,0 +1,17 @@ +class CreatePolls < ActiveRecord::Migration[5.2] + def change + create_table :polls do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.belongs_to :status, foreign_key: { on_delete: :cascade } + t.datetime :expires_at + t.string :options, null: false, array: true, default: [] + t.bigint :cached_tallies, null: false, array: true, default: [] + t.boolean :multiple, null: false, default: false + t.boolean :hide_totals, null: false, default: false + t.bigint :votes_count, null: false, default: 0 + t.datetime :last_fetched_at + + t.timestamps + end + end +end diff --git a/db/migrate/20190225031625_create_poll_votes.rb b/db/migrate/20190225031625_create_poll_votes.rb new file mode 100644 index 000000000..a0849d3a5 --- /dev/null +++ b/db/migrate/20190225031625_create_poll_votes.rb @@ -0,0 +1,11 @@ +class CreatePollVotes < ActiveRecord::Migration[5.2] + def change + create_table :poll_votes do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.belongs_to :poll, foreign_key: { on_delete: :cascade } + t.integer :choice, null: false, default: 0 + + t.timestamps + end + end +end diff --git a/db/migrate/20190226003449_add_poll_id_to_statuses.rb b/db/migrate/20190226003449_add_poll_id_to_statuses.rb new file mode 100644 index 000000000..692e8f814 --- /dev/null +++ b/db/migrate/20190226003449_add_poll_id_to_statuses.rb @@ -0,0 +1,5 @@ +class AddPollIdToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :poll_id, :bigint + end +end diff --git a/db/migrate/20190304152020_add_uri_to_poll_votes.rb b/db/migrate/20190304152020_add_uri_to_poll_votes.rb new file mode 100644 index 000000000..f6b81f1ba --- /dev/null +++ b/db/migrate/20190304152020_add_uri_to_poll_votes.rb @@ -0,0 +1,5 @@ +class AddUriToPollVotes < ActiveRecord::Migration[5.2] + def change + add_column :poll_votes, :uri, :string + end +end diff --git a/db/migrate/20190306145741_add_lock_version_to_polls.rb b/db/migrate/20190306145741_add_lock_version_to_polls.rb new file mode 100644 index 000000000..5bb8cd3b4 --- /dev/null +++ b/db/migrate/20190306145741_add_lock_version_to_polls.rb @@ -0,0 +1,24 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddLockVersionToPolls < ActiveRecord::Migration[5.2] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + add_column_with_default( + :polls, + :lock_version, + :integer, + allow_null: false, + default: 0 + ) + end + end + + def down + remove_column :polls, :lock_version + end +end + diff --git a/db/schema.rb b/db/schema.rb index 05d4deb1a..858cd1e9b 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: 2019_02_03_180359) do +ActiveRecord::Schema.define(version: 2019_03_06_145741) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -452,6 +452,34 @@ ActiveRecord::Schema.define(version: 2019_02_03_180359) do t.index ["database", "captured_at"], name: "index_pghero_space_stats_on_database_and_captured_at" end + create_table "poll_votes", force: :cascade do |t| + t.bigint "account_id" + t.bigint "poll_id" + t.integer "choice", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "uri" + t.index ["account_id"], name: "index_poll_votes_on_account_id" + t.index ["poll_id"], name: "index_poll_votes_on_poll_id" + end + + create_table "polls", force: :cascade do |t| + t.bigint "account_id" + t.bigint "status_id" + t.datetime "expires_at" + t.string "options", default: [], null: false, array: true + t.bigint "cached_tallies", default: [], null: false, array: true + t.boolean "multiple", default: false, null: false + t.boolean "hide_totals", default: false, null: false + t.bigint "votes_count", default: 0, null: false + t.datetime "last_fetched_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "lock_version", default: 0, null: false + t.index ["account_id"], name: "index_polls_on_account_id" + t.index ["status_id"], name: "index_polls_on_status_id" + end + create_table "preview_cards", force: :cascade do |t| t.string "url", default: "", null: false t.string "title", default: "", null: false @@ -593,6 +621,7 @@ ActiveRecord::Schema.define(version: 2019_02_03_180359) do t.bigint "application_id" t.bigint "in_reply_to_account_id" t.boolean "local_only" + t.bigint "poll_id" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc } t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" @@ -760,6 +789,10 @@ ActiveRecord::Schema.define(version: 2019_02_03_180359) do add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id", name: "fk_f5fc4c1ee3", on_delete: :cascade add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id", name: "fk_e84df68546", on_delete: :cascade add_foreign_key "oauth_applications", "users", column: "owner_id", name: "fk_b0988c7c0a", on_delete: :cascade + add_foreign_key "poll_votes", "accounts", on_delete: :cascade + add_foreign_key "poll_votes", "polls", on_delete: :cascade + add_foreign_key "polls", "accounts", on_delete: :cascade + add_foreign_key "polls", "statuses", on_delete: :cascade add_foreign_key "report_notes", "accounts", on_delete: :cascade add_foreign_key "report_notes", "reports", on_delete: :cascade add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index a0e32a7e0..b35dd0561 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 3 + 4 end def pre diff --git a/spec/controllers/api/v1/filter_controller_spec.rb b/spec/controllers/api/v1/filters_controller_spec.rb index 5948809e3..5948809e3 100644 --- a/spec/controllers/api/v1/filter_controller_spec.rb +++ b/spec/controllers/api/v1/filters_controller_spec.rb diff --git a/spec/controllers/api/v1/polls/votes_controller_spec.rb b/spec/controllers/api/v1/polls/votes_controller_spec.rb new file mode 100644 index 000000000..0ee3aa040 --- /dev/null +++ b/spec/controllers/api/v1/polls/votes_controller_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Api::V1::Polls::VotesController, type: :controller do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:scopes) { 'write:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before { allow(controller).to receive(:doorkeeper_token) { token } } + + describe 'POST #create' do + let(:poll) { Fabricate(:poll) } + + before do + post :create, params: { poll_id: poll.id, choices: %w(1) } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'creates a vote' do + vote = poll.votes.where(account: user.account).first + + expect(vote).to_not be_nil + expect(vote.choice).to eq 1 + end + + it 'updates poll tallies' do + expect(poll.reload.cached_tallies).to eq [0, 1] + end + end +end diff --git a/spec/controllers/api/v1/polls_controller_spec.rb b/spec/controllers/api/v1/polls_controller_spec.rb new file mode 100644 index 000000000..2b8d5f3ef --- /dev/null +++ b/spec/controllers/api/v1/polls_controller_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe Api::V1::PollsController, type: :controller do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before { allow(controller).to receive(:doorkeeper_token) { token } } + + describe 'GET #show' do + let(:poll) { Fabricate(:poll) } + + before do + get :show, params: { id: poll.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/fabricators/poll_fabricator.rb b/spec/fabricators/poll_fabricator.rb new file mode 100644 index 000000000..746610f7c --- /dev/null +++ b/spec/fabricators/poll_fabricator.rb @@ -0,0 +1,8 @@ +Fabricator(:poll) do + account + status + expires_at { 7.days.from_now } + options %w(Foo Bar) + multiple false + hide_totals false +end diff --git a/spec/fabricators/poll_vote_fabricator.rb b/spec/fabricators/poll_vote_fabricator.rb new file mode 100644 index 000000000..51f9b006e --- /dev/null +++ b/spec/fabricators/poll_vote_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:poll_vote) do + account + poll + choice 0 +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 26cb84871..3a1463d95 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Create do - let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') } + let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', domain: 'example.com', uri: 'https://example.com/actor') } let(:json) do { @@ -28,6 +28,20 @@ RSpec.describe ActivityPub::Activity::Create do subject.perform end + context 'unknown object type' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Banana', + content: 'Lorem ipsum', + } + end + + it 'does not create a status' do + expect(sender.statuses.count).to be_zero + end + end + context 'standalone' do let(:object_json) do { @@ -407,6 +421,89 @@ RSpec.describe ActivityPub::Activity::Create do expect(status).to_not be_nil end end + + context 'with poll' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Question', + content: 'Which color was the submarine?', + oneOf: [ + { + name: 'Yellow', + replies: { + type: 'Collection', + totalItems: 10, + }, + }, + { + name: 'Blue', + replies: { + type: 'Collection', + totalItems: 3, + } + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + expect(status.poll).to_not be_nil + end + + it 'creates a poll' do + poll = sender.polls.first + expect(poll).to_not be_nil + expect(poll.status).to_not be_nil + expect(poll.options).to eq %w(Yellow Blue) + expect(poll.cached_tallies).to eq [10, 3] + end + end + + context 'when a vote to a local poll' do + let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) } + let!(:local_status) { Fabricate(:status, owned_poll: poll) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + name: 'Yellow', + inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status) + } + end + + it 'adds a vote to the poll with correct uri' do + vote = poll.votes.first + expect(vote).to_not be_nil + expect(vote.uri).to eq object_json[:id] + expect(poll.reload.cached_tallies).to eq [1, 0] + end + end + + context 'when a vote to an expired local poll' do + let(:poll) do + poll = Fabricate.build(:poll, options: %w(Yellow Blue), expires_at: 1.day.ago) + poll.save(validate: false) + poll + end + let!(:local_status) { Fabricate(:status, owned_poll: poll) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + name: 'Yellow', + inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status) + } + end + + it 'does not add a vote to the poll' do + expect(poll.votes.first).to be_nil + end + end end context 'when sender is followed by local users' do diff --git a/spec/models/poll_spec.rb b/spec/models/poll_spec.rb new file mode 100644 index 000000000..666f8ca68 --- /dev/null +++ b/spec/models/poll_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Poll, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/poll_vote_spec.rb b/spec/models/poll_vote_spec.rb new file mode 100644 index 000000000..354afd535 --- /dev/null +++ b/spec/models/poll_vote_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe PollVote, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/serializers/activitypub/note_spec.rb b/spec/serializers/activitypub/note_spec.rb new file mode 100644 index 000000000..55bfbc16b --- /dev/null +++ b/spec/serializers/activitypub/note_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::NoteSerializer do + let!(:account) { Fabricate(:account) } + let!(:other) { Fabricate(:account) } + let!(:parent) { Fabricate(:status, account: account, visibility: :public) } + let!(:reply1) { Fabricate(:status, account: account, thread: parent, visibility: :public) } + let!(:reply2) { Fabricate(:status, account: account, thread: parent, visibility: :public) } + let!(:reply3) { Fabricate(:status, account: other, thread: parent, visibility: :public) } + let!(:reply4) { Fabricate(:status, account: account, thread: parent, visibility: :public) } + let!(:reply5) { Fabricate(:status, account: account, thread: parent, visibility: :direct) } + + before(:each) do + @serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter) + end + + subject { JSON.parse(@serialization.to_json) } + + it 'has a Note type' do + expect(subject['type']).to eql('Note') + end + + it 'has a replies collection' do + expect(subject['replies']['type']).to eql('Collection') + end + + it 'has a replies collection with a first Page' do + expect(subject['replies']['first']['type']).to eql('CollectionPage') + end + + it 'includes public self-replies in its replies collection' do + expect(subject['replies']['first']['items']).to include(reply1.uri, reply2.uri, reply4.uri) + end + + it 'does not include replies from others in its replies collection' do + expect(subject['replies']['first']['items']).to_not include(reply3.uri) + end + + it 'does not include replies with direct visibility in its replies collection' do + expect(subject['replies']['first']['items']).to_not include(reply5.uri) + end +end diff --git a/spec/services/activitypub/fetch_replies_service_spec.rb b/spec/services/activitypub/fetch_replies_service_spec.rb new file mode 100644 index 000000000..65c453341 --- /dev/null +++ b/spec/services/activitypub/fetch_replies_service_spec.rb @@ -0,0 +1,122 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRepliesService, type: :service do + let(:actor) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account') } + let(:status) { Fabricate(:status, account: actor) } + let(:collection_uri) { 'http://example.com/replies/1' } + + let(:items) do + [ + 'http://example.com/self-reply-1', + 'http://example.com/self-reply-2', + 'http://example.com/self-reply-3', + 'http://other.com/other-reply-1', + 'http://other.com/other-reply-2', + 'http://other.com/other-reply-3', + 'http://example.com/self-reply-4', + 'http://example.com/self-reply-5', + 'http://example.com/self-reply-6', + ] + end + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: collection_uri, + items: items, + }.with_indifferent_access + end + + subject { described_class.new } + + describe '#call' do + context 'when the payload is a Collection with inlined replies' do + context 'when passing the collection itself' do + it 'spawns workers for up to 5 replies on the same server' do + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, payload) + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + end + end + + context 'when passing the URL to the collection' do + before do + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + end + + it 'spawns workers for up to 5 replies on the same server' do + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, collection_uri) + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + end + end + end + + context 'when the payload is an OrderedCollection with inlined replies' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollection', + id: collection_uri, + orderedItems: items, + }.with_indifferent_access + end + + context 'when passing the collection itself' do + it 'spawns workers for up to 5 replies on the same server' do + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, payload) + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + end + end + + context 'when passing the URL to the collection' do + before do + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + end + + it 'spawns workers for up to 5 replies on the same server' do + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, collection_uri) + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + end + end + end + + context 'when the payload is a paginated Collection with inlined replies' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: collection_uri, + first: { + type: 'CollectionPage', + partOf: collection_uri, + items: items, + } + }.with_indifferent_access + end + + context 'when passing the collection itself' do + it 'spawns workers for up to 5 replies on the same server' do + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, payload) + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + end + end + + context 'when passing the URL to the collection' do + before do + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload)) + end + + it 'spawns workers for up to 5 replies on the same server' do + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, collection_uri) + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + end + end + end + end +end diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb index 8a5bd3301..6f45762aa 100644 --- a/spec/services/suspend_account_service_spec.rb +++ b/spec/services/suspend_account_service_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe SuspendAccountService, type: :service do - describe '#call' do + describe '#call on local account' do before do stub_request(:post, "https://alice.com/inbox").to_return(status: 201) stub_request(:post, "https://bob.com/inbox").to_return(status: 201) @@ -43,4 +43,46 @@ RSpec.describe SuspendAccountService, type: :service do expect(a_request(:post, "https://bob.com/inbox")).to have_been_made.once end end + + describe '#call on remote account' do + before do + stub_request(:post, "https://alice.com/inbox").to_return(status: 201) + stub_request(:post, "https://bob.com/inbox").to_return(status: 201) + end + + subject do + -> { described_class.new.call(remote_bob) } + end + + let!(:account) { Fabricate(:account) } + let!(:remote_alice) { Fabricate(:account, inbox_url: 'https://alice.com/inbox', protocol: :activitypub) } + let!(:remote_bob) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } + let!(:status) { Fabricate(:status, account: remote_bob) } + let!(:media_attachment) { Fabricate(:media_attachment, account: remote_bob) } + let!(:notification) { Fabricate(:notification, account: remote_bob) } + let!(:favourite) { Fabricate(:favourite, account: remote_bob) } + let!(:active_relationship) { Fabricate(:follow, account: remote_bob, target_account: account) } + let!(:passive_relationship) { Fabricate(:follow, target_account: remote_bob) } + let!(:subscription) { Fabricate(:subscription, account: remote_bob) } + + it 'deletes associated records' do + is_expected.to change { + [ + remote_bob.statuses, + remote_bob.media_attachments, + remote_bob.stream_entries, + remote_bob.notifications, + remote_bob.favourites, + remote_bob.active_relationships, + remote_bob.passive_relationships, + remote_bob.subscriptions + ].map(&:count) + }.from([1, 1, 1, 1, 1, 1, 1, 1]).to([0, 0, 0, 0, 0, 0, 0, 0]) + end + + it 'sends a reject follow to follwer inboxes' do + subject.call + expect(a_request(:post, remote_bob.inbox_url)).to have_been_made.once + end + end end diff --git a/spec/workers/activitypub/fetch_replies_worker_spec.rb b/spec/workers/activitypub/fetch_replies_worker_spec.rb new file mode 100644 index 000000000..91ef3c4b9 --- /dev/null +++ b/spec/workers/activitypub/fetch_replies_worker_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::FetchRepliesWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account, uri: 'https://example.com/user/1') } + let(:status) { Fabricate(:status, account: account) } + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/statuses_replies/1', + type: 'Collection', + items: [], + } + end + + let(:json) { Oj.dump(payload) } + + describe 'perform' do + it 'performs a request if the collection URI is from the same host' do + stub_request(:get, 'https://example.com/statuses_replies/1').to_return(status: 200, body: json) + subject.perform(status.id, 'https://example.com/statuses_replies/1') + expect(a_request(:get, 'https://example.com/statuses_replies/1')).to have_been_made.once + end + + it 'does not perform a request if the collection URI is from a different host' do + stub_request(:get, 'https://other.com/statuses_replies/1').to_return(status: 200) + subject.perform(status.id, 'https://other.com/statuses_replies/1') + expect(a_request(:get, 'https://other.com/statuses_replies/1')).to_not have_been_made + end + + it 'raises when request fails' do + stub_request(:get, 'https://example.com/statuses_replies/1').to_return(status: 500) + expect { subject.perform(status.id, 'https://example.com/statuses_replies/1') }.to raise_error Mastodon::UnexpectedResponseError + end + end +end diff --git a/streaming/index.js b/streaming/index.js index 406ee09e1..869186934 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -24,7 +24,7 @@ const dbUrlToConfig = (dbUrl) => { return {}; } - const params = url.parse(dbUrl); + const params = url.parse(dbUrl, true); const config = {}; if (params.auth) { @@ -45,8 +45,8 @@ const dbUrlToConfig = (dbUrl) => { const ssl = params.query && params.query.ssl; - if (ssl) { - config.ssl = ssl === 'true' || ssl === '1'; + if (ssl && ssl === 'true' || ssl === '1') { + config.ssl = true; } return config; @@ -89,6 +89,7 @@ const startWorker = (workerId) => { host: process.env.DB_HOST || pg.defaults.host, port: process.env.DB_PORT || pg.defaults.port, max: 10, + ssl: !!process.env.DB_SSLMODE && process.env.DB_SSLMODE !== 'disable' ? true : undefined, }, production: { @@ -98,6 +99,7 @@ const startWorker = (workerId) => { host: process.env.DB_HOST || 'localhost', port: process.env.DB_PORT || 5432, max: 10, + ssl: !!process.env.DB_SSLMODE && process.env.DB_SSLMODE !== 'disable' ? true : undefined, }, }; |