diff options
Diffstat (limited to 'app')
182 files changed, 3868 insertions, 949 deletions
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) |