diff options
author | kibigo! <marrus-sh@users.noreply.github.com> | 2017-10-11 10:43:10 -0700 |
---|---|---|
committer | kibigo! <marrus-sh@users.noreply.github.com> | 2017-10-11 10:43:10 -0700 |
commit | 8d6b9ba4946b5b159af0fbd130637a226a286796 (patch) | |
tree | 9def26711682d29338cfa1b081822029a01669eb /app | |
parent | f0a2a6c875e9294f0ea1d4c6bc90529e41a2dc37 (diff) | |
parent | 476e79b8e340c9103352a0799e102e4aca1a5593 (diff) |
Merge upstream 2.0ish #165
Diffstat (limited to 'app')
233 files changed, 4272 insertions, 1620 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 26ab6636b..75915b337 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -26,7 +26,10 @@ class AccountsController < ApplicationController end format.json do - render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: @account, + serializer: ActivityPub::ActorSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index b37910b36..76553a162 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -9,9 +9,9 @@ class ActivityPub::InboxesController < Api::BaseController if signed_request_account upgrade_account process_payload - head 201 - else head 202 + else + [signature_verification_failure_reason, 401] end end @@ -32,6 +32,7 @@ class ActivityPub::InboxesController < Api::BaseController end Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? + DeliveryFailureTracker.track_inverse_success!(signed_request_account) end def process_payload diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb new file mode 100644 index 000000000..414a875d0 --- /dev/null +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Admin::AccountModerationNotesController < Admin::BaseController + def create + @account_moderation_note = current_account.account_moderation_notes.new(resource_params) + if @account_moderation_note.save + @target_account = @account_moderation_note.target_account + redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg') + else + @account = @account_moderation_note.target_account + @moderation_notes = @account.targeted_moderation_notes.latest + render template: 'admin/accounts/show' + end + end + + def destroy + @account_moderation_note = AccountModerationNote.find(params[:id]) + @target_account = @account_moderation_note.target_account + @account_moderation_note.destroy + redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg') + end + + private + + def resource_params + params.require(:account_moderation_note).permit( + :content, + :target_account_id + ) + end +end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 54c659e1b..ffa4dc850 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -9,7 +9,10 @@ module Admin @accounts = filtered_accounts.page(params[:page]) end - def show; end + def show + @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) + @moderation_notes = @account.targeted_moderation_notes.latest + end def subscribe Pubsubhubbub::SubscribeWorker.perform_async(@account.id) diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index d70514d9a..ca81f3255 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -2,8 +2,10 @@ module Admin class CustomEmojisController < BaseController + before_action :set_custom_emoji, except: [:index, :new, :create] + def index - @custom_emojis = CustomEmoji.local + @custom_emojis = filtered_custom_emojis.page(params[:page]) end def new @@ -21,14 +23,49 @@ module Admin end def destroy - CustomEmoji.find(params[:id]).destroy + @custom_emoji.destroy redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') end + def copy + emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image) + + if emoji.save + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.copied_msg') + else + redirect_to admin_custom_emojis_path, alert: I18n.t('admin.custom_emojis.copy_failed_msg') + end + end + + def enable + @custom_emoji.update!(disabled: false) + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg') + end + + def disable + @custom_emoji.update!(disabled: true) + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg') + end + private + def set_custom_emoji + @custom_emoji = CustomEmoji.find(params[:id]) + end + def resource_params params.require(:custom_emoji).permit(:shortcode, :image) end + + def filtered_custom_emojis + CustomEmojiFilter.new(filter_params).results + end + + def filter_params + params.permit( + :local, + :remote + ) + end end end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb new file mode 100644 index 000000000..09275d5dc --- /dev/null +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Admin + class EmailDomainBlocksController < BaseController + before_action :set_email_domain_block, only: [:show, :destroy] + + def index + @email_domain_blocks = EmailDomainBlock.page(params[:page]) + end + + def new + @email_domain_block = EmailDomainBlock.new + end + + def create + @email_domain_block = EmailDomainBlock.new(resource_params) + + if @email_domain_block.save + redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') + else + render :new + end + end + + def destroy + @email_domain_block.destroy + redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg') + end + + private + + def set_email_domain_block + @email_domain_block = EmailDomainBlock.find(params[:id]) + end + + def resource_params + params.require(:email_domain_block).permit(:domain) + end + end +end diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb index e9e700b18..143e9d3cd 100644 --- a/app/controllers/api/salmon_controller.rb +++ b/app/controllers/api/salmon_controller.rb @@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController def update if verify_payload? process_salmon - head 201 - else head 202 + elsif payload.present? + [signature_verification_failure_reason, 401] + else + head 400 end end diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index a88cf2021..91a942d75 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController respond_to :json def index - @accounts = Account.where(id: account_ids).select('id') + accounts = Account.where(id: account_ids).select('id') + # .where doesn't guarantee that our results are in the same order + # we requested them, so return the "right" order to the requestor. + @accounts = accounts.index_by(&:id).values_at(*account_ids) render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb new file mode 100644 index 000000000..e469c7d21 --- /dev/null +++ b/app/controllers/api/v1/apps/credentials_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Api::V1::Apps::CredentialsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + + respond_to :json + + def show + render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer + end +end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 44a27b20a..e9f7a7291 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Api::V1::AppsController < Api::BaseController - respond_to :json - def create @app = Doorkeeper::Application.create!(application_options) render json: @app, serializer: REST::ApplicationSerializer diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index a412e4341..3a6690766 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -15,19 +15,17 @@ class Api::V1::BlocksController < Api::BaseController private def load_accounts - default_accounts.merge(paginated_blocks).to_a - end - - def default_accounts - Account.includes(:blocked_by).references(:blocked_by) + paginated_blocks.map(&:target_account) end def paginated_blocks - Block.where(account: current_account).paginate_by_max_id( - limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id] - ) + @paginated_blocks ||= Block.eager_load(:target_account) + .where(account: current_account) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) end def insert_pagination_headers @@ -41,21 +39,21 @@ class Api::V1::BlocksController < Api::BaseController end def prev_path - unless @accounts.empty? + unless paginated_blocks.empty? api_v1_blocks_url pagination_params(since_id: pagination_since_id) end end def pagination_max_id - @accounts.last.blocked_by_ids.last + paginated_blocks.last.id end def pagination_since_id - @accounts.first.blocked_by_ids.first + paginated_blocks.first.id end def records_continue? - @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end def pagination_params(core_params) diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 4dd77fb55..f8cd64455 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -4,6 +4,6 @@ class Api::V1::CustomEmojisController < Api::BaseController respond_to :json def index - render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer + render json: CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer end end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 8a1992fca..9f330f0df 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController respond_to :json def create - @media = current_account.media_attachments.create!(file: media_params[:file]) + @media = current_account.media_attachments.create!(media_params) render json: @media, serializer: REST::MediaAttachmentSerializer rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: file_type_error, status: 422 @@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController render json: processing_error, status: 500 end + def update + @media = current_account.media_attachments.where(status_id: nil).find(params[:id]) + @media.update!(media_params) + render json: @media, serializer: REST::MediaAttachmentSerializer + end + private def media_params - params.permit(:file) + params.permit(:file, :description) end def file_type_error diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 60ace04d7..aac3c31ff 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -6,6 +6,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] + before_action :set_instance_presenter, only: [:new, :update] def destroy not_found @@ -39,6 +40,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController private + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def determine_layout %w(edit update).include?(action_name) ? 'admin' : 'auth' end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index bc3bd2f4b..463a183e4 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:create] skip_before_action :check_suspension, only: [:destroy] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + before_action :set_instance_presenter, only: [:new] def create super do |resource| @@ -84,6 +85,10 @@ class Auth::SessionsController < Devise::SessionsController private + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def home_paths(resource) paths = [about_path] if single_user_mode? && resource.is_a?(User) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4211283ed..2baafb5bf 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -9,10 +9,15 @@ module SignatureVerification request.headers['Signature'].present? end + def signature_verification_failure_reason + return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason) + end + def signed_request_account return @signed_request_account if defined?(@signed_request_account) unless signed_request? + @signature_verification_failure_reason = 'Request not signed' @signed_request_account = nil return end @@ -27,6 +32,7 @@ module SignatureVerification end if incompatible_signature?(signature_params) + @signature_verification_failure_reason = 'Incompatible request signature' @signed_request_account = nil return end @@ -34,6 +40,7 @@ module SignatureVerification account = account_from_key_id(signature_params['keyId']) if account.nil? + @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" @signed_request_account = nil return end @@ -44,7 +51,18 @@ module SignatureVerification if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) @signed_request_account = account @signed_request_account + elsif account.possibly_stale? + account = account.refresh! + + if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) + @signed_request_account = account + @signed_request_account + else + @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" + @signed_request_account = nil + end else + @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" @signed_request_account = nil end end @@ -99,7 +117,7 @@ module SignatureVerification ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) - account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) + account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) account end end diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb new file mode 100644 index 000000000..a82b9340b --- /dev/null +++ b/app/controllers/emojis_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class EmojisController < ApplicationController + before_action :set_emoji + + def show + respond_to do |format| + format.json do + render json: @emoji, + serializer: ActivityPub::EmojiSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_emoji + @emoji = CustomEmoji.local.find(params[:id]) + end +end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 8eb4d2822..399e79665 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -10,7 +10,10 @@ class FollowerAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 1ca6f0fe7..1e73d4bd4 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -10,7 +10,10 @@ class FollowingAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb index 832e1eb6f..ac267c229 100644 --- a/app/controllers/manifests_controller.rb +++ b/app/controllers/manifests_controller.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true class ManifestsController < ApplicationController - before_action :set_instance_presenter - - def show; end - - def set_instance_presenter - @instance_presenter = InstancePresenter.new + def show + render json: InstancePresenter.new, serializer: ManifestSerializer end end diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb index 90b48887f..9968504e5 100644 --- a/app/controllers/settings/follower_domains_controller.rb +++ b/app/controllers/settings/follower_domains_controller.rb @@ -9,7 +9,7 @@ class Settings::FollowerDomainsController < ApplicationController def show @account = current_account - @domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) + @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) end def update diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb new file mode 100644 index 000000000..09839f16e --- /dev/null +++ b/app/controllers/settings/notifications_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Settings::NotificationsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + + def show; end + + def update + user_settings.update(user_settings_params.to_h) + + if current_user.save + redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + private + + def user_settings + UserSettingsDecorator.new(current_user) + end + + def user_settings_params + params.require(:user).permit( + notification_emails: %i(follow follow_request reblog favourite mention digest), + interactions: %i(must_be_follower must_be_following) + ) + end +end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 65206ea96..e8a360fb5 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -21,13 +21,19 @@ class StatusesController < ApplicationController end format.json do - render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: @status, + serializer: ActivityPub::NoteSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end def activity - render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: @status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end def embed diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 3001b2ee3..9f3090e37 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,24 +1,40 @@ # frozen_string_literal: true class TagsController < ApplicationController - layout 'public' + before_action :set_body_classes + before_action :set_instance_presenter def show - @tag = Tag.find_by!(name: params[:id].downcase) - @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) - @statuses = cache_collection(@statuses, Status) + @tag = Tag.find_by!(name: params[:id].downcase) respond_to do |format| - format.html + format.html do + serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) + @initial_state_json = serializable_resource.to_json + end format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) + @statuses = cache_collection(@statuses, Status) + + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end private + def set_body_classes + @body_classes = 'tag-body' + end + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def collection_presenter ActivityPub::CollectionPresenter.new( id: tag_url(@tag), @@ -27,4 +43,11 @@ class TagsController < ApplicationController items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } ) end + + def initial_state_params + { + settings: {}, + token: current_session&.token, + } + end end diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb new file mode 100644 index 000000000..b17c52264 --- /dev/null +++ b/app/helpers/admin/account_moderation_notes_helper.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +module Admin::AccountModerationNotesHelper +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index d82a07332..c23a2e095 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -22,7 +22,18 @@ module JsonLdHelper graph.dump(:normalize) end - def fetch_resource(uri) + def fetch_resource(uri, id) + unless id + json = fetch_resource_without_id_validation(uri) + return unless json + uri = json['id'] + end + + json = fetch_resource_without_id_validation(uri) + json.present? && json['id'] == uri ? json : nil + end + + def fetch_resource_without_id_validation(uri) response = build_request(uri).perform return if response.code != 200 body_to_json(response.to_s) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 20cb09f58..8d035e82f 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,5 +1,7 @@ import api from '../api'; -import { emojiIndex } from 'emoji-mart'; +import { throttle } from 'lodash'; +import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +import { useEmoji } from './emojis'; import { updateTimeline, @@ -15,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; +export const COMPOSE_RESET = 'COMPOSE_RESET'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; @@ -38,6 +41,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; +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 function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -64,6 +71,12 @@ export function cancelReplyCompose() { }; }; +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +}; + export function mentionCompose(account, router) { return (dispatch, getState) => { dispatch({ @@ -168,6 +181,40 @@ export function uploadCompose(files) { }; }; +export function changeUploadCompose(id, description) { + return (dispatch, getState) => { + dispatch(changeUploadComposeRequest()); + + api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { + dispatch(changeUploadComposeSuccess(response.data)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + }; +}; + +export function changeUploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + skipLoading: true, + }; +}; +export function changeUploadComposeSuccess(media) { + return { + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + media: media, + skipLoading: true, + }; +}; + +export function changeUploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_CHANGE_FAIL, + error: error, + skipLoading: true, + }; +}; + export function uploadComposeRequest() { return { type: COMPOSE_UPLOAD_REQUEST, @@ -212,23 +259,30 @@ export function clearComposeSuggestions() { }; }; +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { + api(getState).get('/api/v1/accounts/search', { + params: { + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(response => { + dispatch(readyComposeSuggestionsAccounts(token, response.data)); + }); +}, 200, { leading: true, trailing: true }); + +const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); + dispatch(readyComposeSuggestionsEmojis(token, results)); +}; + export function fetchComposeSuggestions(token) { return (dispatch, getState) => { if (token[0] === ':') { - const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 }); - dispatch(readyComposeSuggestionsEmojis(token, results)); - return; + fetchComposeSuggestionsEmojis(dispatch, getState, token); + } else { + fetchComposeSuggestionsAccounts(dispatch, getState, token); } - - api(getState).get('/api/v1/accounts/search', { - params: { - q: token.slice(1), - resolve: false, - limit: 4, - }, - }).then(response => { - dispatch(readyComposeSuggestionsAccounts(token, response.data)); - }); }; }; @@ -255,6 +309,8 @@ export function selectComposeSuggestion(position, token, suggestion) { if (typeof suggestion === 'object' && suggestion.id) { completion = suggestion.native || suggestion.colons; startPosition = position - 1; + + dispatch(useEmoji(suggestion)); } else { completion = getState().getIn(['accounts', suggestion, 'acct']); startPosition = position; diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js new file mode 100644 index 000000000..7cd9d4b7b --- /dev/null +++ b/app/javascript/mastodon/actions/emojis.js @@ -0,0 +1,14 @@ +import { saveSettings } from './settings'; + +export const EMOJI_USE = 'EMOJI_USE'; + +export function useEmoji(emoji) { + return dispatch => { + dispatch({ + type: EMOJI_USE, + emoji, + }); + + dispatch(saveSettings()); + }; +}; diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index f9d304c96..79adca18c 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -1,6 +1,8 @@ import axios from 'axios'; +import { debounce } from 'lodash'; export const SETTING_CHANGE = 'SETTING_CHANGE'; +export const SETTING_SAVE = 'SETTING_SAVE'; export function changeSetting(key, value) { return dispatch => { @@ -14,10 +16,16 @@ export function changeSetting(key, value) { }; }; +const debouncedSave = debounce((dispatch, getState) => { + if (getState().getIn(['settings', 'saved'])) { + return; + } + + const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS(); + + axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE })); +}, 5000, { trailing: true }); + export function saveSettings() { - return (_, getState) => { - axios.put('/api/web/settings', { - data: getState().get('settings').toJS(), - }); - }; + return (dispatch, getState) => debouncedSave(dispatch, getState); }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 5c0cd93c7..09abe2702 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; + export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { return { type: TIMELINE_REFRESH_SUCCESS, @@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { export function updateTimeline(timeline, status) { return (dispatch, getState) => { const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; + const parents = []; + + if (status.in_reply_to_id) { + let parent = getState().getIn(['statuses', status.in_reply_to_id]); + + while (parent && parent.get('in_reply_to_id')) { + parents.push(parent.get('id')); + parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); + } + } dispatch({ type: TIMELINE_UPDATE, @@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) { status, references, }); + + if (parents.length > 0) { + dispatch({ + type: TIMELINE_CONTEXT_UPDATE, + status, + references: parents, + }); + } }; }; diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js index e2866e8e4..ce4383a60 100644 --- a/app/javascript/mastodon/components/autosuggest_emoji.js +++ b/app/javascript/mastodon/components/autosuggest_emoji.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { unicodeMapping } from '../emojione_light'; +import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; const assetHost = process.env.CDN_HOST || ''; @@ -17,8 +17,13 @@ export default class AutosuggestEmoji extends React.PureComponent { if (emoji.custom) { url = emoji.imageUrl; } else { - const [ filename ] = unicodeMapping[emoji.native]; - url = `${assetHost}/emoji/${filename}.svg`; + const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + url = `${assetHost}/emoji/${mapping.filename}.svg`; } return ( diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 6f725885d..14a8d4c38 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { this.props.onKeyDown(e); } + onKeyUp = e => { + if (e.key === 'Escape' && this.state.suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } + + if (this.props.onKeyUp) { + this.props.onKeyUp(e); + } + } + onBlur = () => { this.setState({ suggestionsHidden: true }); } @@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } render () { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; + const { value, suggestions, disabled, placeholder, autoFocus } = this.props; const { suggestionsHidden } = this.state; const style = { direction: 'ltr' }; @@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { value={value} onChange={this.onChange} onKeyDown={this.onKeyDown} - onKeyUp={onKeyUp} + onKeyUp={this.onKeyUp} onBlur={this.onBlur} onPaste={this.onPaste} style={style} diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index e0042b055..c47296a51 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -173,7 +173,7 @@ export default class ColumnHeader extends React.PureComponent { return ( <div className={wrapperClassName}> - <h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> + <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> <i className={`fa fa-fw fa-${icon} column-header__icon`} /> {title} <div className='column-header__buttons'> @@ -200,7 +200,7 @@ export default class ColumnHeader extends React.PureComponent { </div> ) : null} - <div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}> + <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> <div className='column-header__collapsible-inner'> {(!collapsed || animating) && collapsedContent} </div> diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index c0fbcab6d..73ad46bb7 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -2,8 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import IconButton from './icon_button'; -import { Overlay } from 'react-overlays'; -import { Motion, spring } from 'react-motion'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'react-motion/lib/Motion'; +import spring from 'react-motion/lib/spring'; import detectPassiveEvents from 'detect-passive-events'; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js index 5ab5e9e58..f8bd067e8 100644 --- a/app/javascript/mastodon/components/extended_video_player.js +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { static propTypes = { src: PropTypes.string.isRequired, + alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, time: PropTypes.number, @@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent { } render () { + const { src, muted, controls, alt } = this.props; + return ( <div className='extended-video-player'> <video ref={this.setRef} - src={this.props.src} + src={src} autoPlay - muted={this.props.muted} - controls={this.props.controls} - loop={!this.props.controls} + role='button' + tabIndex='0' + aria-label={alt} + muted={muted} + controls={controls} + loop={!controls} /> </div> ); diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js index 575743350..e2ce9ec96 100644 --- a/app/javascript/mastodon/components/intersection_observer_article.js +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -58,26 +58,31 @@ export default class IntersectionObserverArticle extends React.Component { } handleIntersection = (entry) => { - const { onHeightChange, saveHeightKey, id } = this.props; + this.entry = entry; - if (this.node && this.node.children.length !== 0) { - // save the height of the fully-rendered element - this.height = getRectFromEntry(entry).height; + scheduleIdleTask(this.calculateHeight); + this.setState(this.updateStateAfterIntersection); + } - if (onHeightChange && saveHeightKey) { - onHeightChange(saveHeightKey, id, this.height); - } + updateStateAfterIntersection = (prevState) => { + if (prevState.isIntersecting && !this.entry.isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); } + return { + isIntersecting: this.entry.isIntersecting, + isHidden: false, + }; + } - this.setState((prevState) => { - if (prevState.isIntersecting && !entry.isIntersecting) { - scheduleIdleTask(this.hideIfNotIntersecting); - } - return { - isIntersecting: entry.isIntersecting, - isHidden: false, - }; - }); + calculateHeight = () => { + const { onHeightChange, saveHeightKey, id } = this.props; + // save the height of the fully-rendered element (this is expensive + // on Chrome, where we need to fall back to getBoundingClientRect) + this.height = getRectFromEntry(this.entry).height; + + if (onHeightChange && saveHeightKey) { + onHeightChange(saveHeightKey, id, this.height); + } } hideIfNotIntersecting = () => { diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 8bc1427d9..83cf8b871 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -9,7 +9,6 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; -import sizeMe from 'react-sizeme'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, @@ -139,7 +138,7 @@ class Item extends React.PureComponent { onClick={this.handleClick} target='_blank' > - <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> + <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> </a> ); } else if (attachment.get('type') === 'gifv') { @@ -149,6 +148,7 @@ class Item extends React.PureComponent { <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <video className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} role='application' src={attachment.get('url')} onClick={this.handleClick} @@ -174,7 +174,6 @@ class Item extends React.PureComponent { } @injectIntl -@sizeMe({}) export default class MediaGallery extends React.PureComponent { static propTypes = { @@ -211,21 +210,42 @@ export default class MediaGallery extends React.PureComponent { this.props.onOpenMedia(this.props.media, index); } + handleRef = (node) => { + if (node && this.isStandaloneEligible()) { + // offsetWidth triggers a layout, so only calculate when we need to + this.setState({ + width: node.offsetWidth, + }); + } + } + + isStandaloneEligible() { + const { media, standalone } = this.props; + return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + } + render () { - const { media, intl, sensitive, height, standalone, size } = this.props; + const { media, intl, sensitive, height } = this.props; + const { width, visible } = this.state; let children; - const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); const style = {}; - if (standaloneEligible) { - style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']); + if (this.isStandaloneEligible()) { + if (!visible && width) { + // only need to forcibly set the height in "sensitive" mode + style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); + } else { + // layout automatically, using image's natural aspect ratio + style.height = ''; + } } else { + // crop the image style.height = height; } - if (!this.state.visible) { + if (!visible) { let warning; if (sensitive) { @@ -235,7 +255,7 @@ export default class MediaGallery extends React.PureComponent { } children = ( - <button className='media-spoiler' onClick={this.handleOpen} style={style}> + <button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> <span className='media-spoiler__warning'>{warning}</span> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> </button> @@ -243,7 +263,7 @@ export default class MediaGallery extends React.PureComponent { } else { const size = media.take(4).size; - if (standaloneEligible) { + if (this.isStandaloneEligible()) { children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />; } else { children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />); @@ -252,8 +272,8 @@ export default class MediaGallery extends React.PureComponent { return ( <div className='media-gallery' style={style}> - <div className={classNames('spoiler-button', { 'spoiler-button--visible': this.state.visible })}> - <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> + <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> </div> {children} diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js index 2717d2326..534d83fac 100644 --- a/app/javascript/mastodon/components/relative_timestamp.js +++ b/app/javascript/mastodon/components/relative_timestamp.js @@ -1,7 +1,15 @@ import React from 'react'; -import { injectIntl, FormattedRelative } from 'react-intl'; +import { injectIntl, defineMessages } from 'react-intl'; import PropTypes from 'prop-types'; +const messages = defineMessages({ + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, +}); + const dateFormatOptions = { hour12: false, year: 'numeric', @@ -11,6 +19,47 @@ const dateFormatOptions = { minute: '2-digit', }; +const shortDateFormatOptions = { + month: 'numeric', + day: 'numeric', +}; + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const MAX_DELAY = 2147483647; + +const selectUnits = delta => { + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return 'second'; + } else if (absDelta < HOUR) { + return 'minute'; + } else if (absDelta < DAY) { + return 'hour'; + } + + return 'day'; +}; + +const getUnitDelay = units => { + switch (units) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + default: + return MAX_DELAY; + } +}; + @injectIntl export default class RelativeTimestamp extends React.Component { @@ -19,20 +68,74 @@ export default class RelativeTimestamp extends React.Component { timestamp: PropTypes.string.isRequired, }; - shouldComponentUpdate (nextProps) { + state = { + now: this.props.intl.now(), + }; + + shouldComponentUpdate (nextProps, nextState) { // As of right now the locale doesn't change without a new page load, // but we might as well check in case that ever changes. return this.props.timestamp !== nextProps.timestamp || - this.props.intl.locale !== nextProps.intl.locale; + this.props.intl.locale !== nextProps.intl.locale || + this.state.now !== nextState.now; + } + + componentWillReceiveProps (nextProps) { + if (this.props.timestamp !== nextProps.timestamp) { + this.setState({ now: this.props.intl.now() }); + } + } + + componentDidMount () { + this._scheduleNextUpdate(this.props, this.state); + } + + componentWillUpdate (nextProps, nextState) { + this._scheduleNextUpdate(nextProps, nextState); + } + + _scheduleNextUpdate (props, state) { + clearTimeout(this._timer); + + const { timestamp } = props; + const delta = (new Date(timestamp)).getTime() - state.now; + const unitDelay = getUnitDelay(selectUnits(delta)); + const unitRemainder = Math.abs(delta % unitDelay); + const updateInterval = 1000 * 10; + const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); + + this._timer = setTimeout(() => { + this.setState({ now: this.props.intl.now() }); + }, delay); } render () { const { timestamp, intl } = this.props; - const date = new Date(timestamp); + + const date = new Date(timestamp); + const delta = this.state.now - date.getTime(); + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.just_now); + } else if (delta < 3 * DAY) { + 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) }); + } + } else { + relativeTime = intl.formatDate(date, shortDateFormatOptions); + } return ( <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> - <FormattedRelative value={date} /> + {relativeTime} </time> ); } diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index ff0540e5d..ab9d48510 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -6,6 +6,8 @@ import LoadMore from './load_more'; import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import { throttle } from 'lodash'; import { List as ImmutableList } from 'immutable'; +import classNames from 'classnames'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; export default class ScrollableList extends PureComponent { @@ -66,6 +68,7 @@ export default class ScrollableList extends PureComponent { componentDidMount () { this.attachScrollListener(); this.attachIntersectionObserver(); + attachFullscreenListener(this.onFullScreenChange); // Handle initial scroll posiiton this.handleScroll(); @@ -92,6 +95,11 @@ export default class ScrollableList extends PureComponent { componentWillUnmount () { this.detachScrollListener(); this.detachIntersectionObserver(); + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); } attachIntersectionObserver () { @@ -137,34 +145,9 @@ export default class ScrollableList extends PureComponent { return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); } - handleKeyDown = (e) => { - if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { - const article = (() => { - switch (e.key) { - case 'PageDown': - return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; - case 'PageUp': - return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; - case 'End': - return this.node.querySelector('[role="feed"] > article:last-of-type'); - case 'Home': - return this.node.querySelector('[role="feed"] > article:first-of-type'); - default: - return null; - } - })(); - - - if (article) { - e.preventDefault(); - article.focus(); - article.scrollIntoView(); - } - } - } - render () { const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const { fullscreen } = this.state; const childrenCount = React.Children.count(children); const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; @@ -172,8 +155,8 @@ export default class ScrollableList extends PureComponent { if (isLoading || childrenCount > 0 || !emptyMessage) { scrollableArea = ( - <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> - <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> + <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> + <div role='feed' className='item-list'> {prepend} {React.Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9e65db85c..b9be20033 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -13,6 +13,8 @@ import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -42,6 +44,8 @@ export default class Status extends ImmutablePureComponent { autoPlayGif: PropTypes.bool, muted: PropTypes.bool, hidden: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, }; state = { @@ -92,16 +96,62 @@ export default class Status extends ImmutablePureComponent { } handleOpenVideo = startTime => { - this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this._properStatus(), this.context.router.history); + } + + handleHotkeyFavourite = () => { + this.props.onFavourite(this._properStatus()); + } + + handleHotkeyBoost = e => { + this.props.onReblog(this._properStatus(), e); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this._properStatus().get('account'), this.context.router.history); + } + + handleHotkeyOpen = () => { + this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); + } + + handleHotkeyOpenProfile = () => { + this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); + } + + handleHotkeyMoveUp = () => { + this.props.onMoveUp(this.props.status.get('id')); + } + + handleHotkeyMoveDown = () => { + this.props.onMoveDown(this.props.status.get('id')); + } + + _properStatus () { + const { status } = this.props; + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + return status.get('reblog'); + } else { + return status; + } } render () { let media = null; - let statusAvatar; + let statusAvatar, prepend; - const { status, account, hidden, ...other } = this.props; + const { hidden } = this.props; const { isExpanded } = this.state; + let { status, account, ...other } = this.props; + if (status === null) { return null; } @@ -118,16 +168,15 @@ export default class Status extends ImmutablePureComponent { if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; - return ( - <div className='status__wrapper' data-id={status.get('id')} > - <div className='status__prepend'> - <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> - <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> - </div> - - <Status {...other} status={status.get('reblog')} account={status.get('account')} /> + prepend = ( + <div className='status__prepend'> + <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> + <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> </div> ); + + account = status.get('account'); + status = status.get('reblog'); } if (status.get('media_attachments').size > 0 && !this.props.muted) { @@ -163,26 +212,43 @@ export default class Status extends ImmutablePureComponent { statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; } + const handlers = this.props.muted ? {} : { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + return ( - <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> - <div className='status__info'> - <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + <HotKeys handlers={handlers}> + <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}> + {prepend} - <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> - <div className='status__avatar'> - {statusAvatar} - </div> + <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}> + <div className='status__info'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> - <DisplayName account={status.get('account')} /> - </a> - </div> + <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'> + <div className='status__avatar'> + {statusAvatar} + </div> - <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> - {media} + {media} - <StatusActionBar {...this.props} /> - </div> + <StatusActionBar status={status} account={account} {...other} /> + </div> + </div> + </HotKeys> ); } diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index d1381f176..8ad60b9d6 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -147,7 +147,7 @@ export default class StatusContent extends React.PureComponent { } return ( - <div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> <span dangerouslySetInnerHTML={spoilerContent} /> {' '} @@ -164,7 +164,6 @@ export default class StatusContent extends React.PureComponent { <div ref={this.setRef} tabIndex='0' - aria-label={status.get('search_index')} className={classNames} style={directionStyle} onMouseDown={this.handleMouseDown} @@ -176,7 +175,6 @@ export default class StatusContent extends React.PureComponent { return ( <div tabIndex='0' - aria-label={status.get('search_index')} ref={this.setRef} className='status__content' style={directionStyle} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 9026ebb0c..214955591 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent { trackScroll: true, }; + handleMoveUp = id => { + const elementIndex = this.props.statusIds.indexOf(id) - 1; + this._selectChild(elementIndex); + } + + handleMoveDown = id => { + const elementIndex = this.props.statusIds.indexOf(id) + 1; + this._selectChild(elementIndex); + } + + _selectChild (index) { + const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + element.focus(); + } + } + + setRef = c => { + this.node = c; + } + render () { const { statusIds, ...other } = this.props; const { isLoading } = other; const scrollableContent = (isLoading || statusIds.size > 0) ? ( statusIds.map((statusId) => ( - <StatusContainer key={statusId} id={statusId} /> + <StatusContainer + key={statusId} + id={statusId} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> )) ) : null; return ( - <ScrollableList {...other}> + <ScrollableList {...other} ref={this.setRef}> {scrollableContent} </ScrollableList> ); diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js deleted file mode 100644 index 26914f113..000000000 --- a/app/javascript/mastodon/components/video_player.js +++ /dev/null @@ -1,207 +0,0 @@ -// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! -// SEE INSTEAD : glitch/components/status/player - -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; -import IconButton from './icon_button'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { isIOS } from '../is_mobile'; - -const messages = defineMessages({ - toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, - toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, - expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, -}); - -@injectIntl -export default class VideoPlayer extends React.PureComponent { - - static contextTypes = { - router: PropTypes.object, - }; - - static propTypes = { - media: ImmutablePropTypes.map.isRequired, - width: PropTypes.number, - height: PropTypes.number, - sensitive: PropTypes.bool, - intl: PropTypes.object.isRequired, - autoplay: PropTypes.bool, - onOpenVideo: PropTypes.func.isRequired, - }; - - static defaultProps = { - width: 239, - height: 110, - }; - - state = { - visible: !this.props.sensitive, - preview: true, - muted: true, - hasAudio: true, - videoError: false, - }; - - handleClick = () => { - this.setState({ muted: !this.state.muted }); - } - - handleVideoClick = (e) => { - e.stopPropagation(); - - const node = this.video; - - if (node.paused) { - node.play(); - } else { - node.pause(); - } - } - - handleOpen = () => { - this.setState({ preview: !this.state.preview }); - } - - handleVisibility = () => { - this.setState({ - visible: !this.state.visible, - preview: true, - }); - } - - handleExpand = () => { - this.video.pause(); - this.props.onOpenVideo(this.props.media, this.video.currentTime); - } - - setRef = (c) => { - this.video = c; - } - - handleLoadedData = () => { - if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { - this.setState({ hasAudio: false }); - } - } - - handleVideoError = () => { - this.setState({ videoError: true }); - } - - componentDidMount () { - if (!this.video) { - return; - } - - this.video.addEventListener('loadeddata', this.handleLoadedData); - this.video.addEventListener('error', this.handleVideoError); - } - - componentDidUpdate () { - if (!this.video) { - return; - } - - this.video.addEventListener('loadeddata', this.handleLoadedData); - this.video.addEventListener('error', this.handleVideoError); - } - - componentWillUnmount () { - if (!this.video) { - return; - } - - this.video.removeEventListener('loadeddata', this.handleLoadedData); - this.video.removeEventListener('error', this.handleVideoError); - } - - render () { - const { media, intl, width, height, sensitive, autoplay } = this.props; - - let spoilerButton = ( - <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}> - <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> - </div> - ); - - let expandButton = ''; - - if (this.context.router) { - expandButton = ( - <div className='status__video-player-expand'> - <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} /> - </div> - ); - } - - let muteButton = ''; - - if (this.state.hasAudio) { - muteButton = ( - <div className='status__video-player-mute'> - <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> - </div> - ); - } - - if (!this.state.visible) { - if (sensitive) { - return ( - <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> - {spoilerButton} - <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> - <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </button> - ); - } else { - return ( - <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> - {spoilerButton} - <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> - <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </button> - ); - } - } - - if (this.state.preview && !autoplay) { - return ( - <button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> - {spoilerButton} - <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> - </button> - ); - } - - if (this.state.videoError) { - return ( - <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' > - <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span> - </div> - ); - } - - return ( - <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}> - {spoilerButton} - {muteButton} - {expandButton} - - <video - className='status__video-player-video' - role='button' - tabIndex='0' - ref={this.setRef} - src={media.get('url')} - autoPlay={!isIOS()} - loop - muted={this.state.muted} - onClick={this.handleVideoClick} - /> - </div> - ); - } - -} diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index db2a5f269..6beffca1c 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -3,9 +3,8 @@ import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; import { showOnboardingOnce } from '../actions/onboarding'; -import BrowserRouter from 'react-router-dom/BrowserRouter'; -import Route from 'react-router-dom/Route'; -import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; +import { BrowserRouter, Route } from 'react-router-dom'; +import { ScrollContext } from 'react-router-scroll'; import UI from '../features/ui'; import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js index 6b545ef09..4be037955 100644 --- a/app/javascript/mastodon/containers/timeline_container.js +++ b/app/javascript/mastodon/containers/timeline_container.js @@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; import PublicTimeline from '../features/standalone/public_timeline'; +import HashtagTimeline from '../features/standalone/hashtag_timeline'; const { localeData, messages } = getLocale(); addLocaleData(localeData); @@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent { static propTypes = { locale: PropTypes.string.isRequired, + hashtag: PropTypes.string, }; render () { - const { locale } = this.props; + const { locale, hashtag } = this.props; + + let timeline; + + if (hashtag) { + timeline = <HashtagTimeline hashtag={hashtag} />; + } else { + timeline = <PublicTimeline />; + } return ( <IntlProvider locale={locale} messages={messages}> <Provider store={store}> - <PublicTimeline /> + {timeline} </Provider> </IntlProvider> ); diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js deleted file mode 100644 index d75f6f598..000000000 --- a/app/javascript/mastodon/emoji.js +++ /dev/null @@ -1,72 +0,0 @@ -import { unicodeMapping } from './emojione_light'; -import Trie from 'substring-trie'; - -const trie = new Trie(Object.keys(unicodeMapping)); - -const assetHost = process.env.CDN_HOST || ''; - -const emojify = (str, customEmojis = {}) => { - let rtn = ''; - for (;;) { - let match, i = 0, tag; - while (i < str.length && (tag = '<&'.indexOf(str[i])) === -1 && str[i] !== ':' && !(match = trie.search(str.slice(i)))) { - i += str.codePointAt(i) < 65536 ? 1 : 2; - } - if (i === str.length) - break; - else if (tag >= 0) { - const tagend = str.indexOf('>;'[tag], i + 1) + 1; - if (!tagend) - break; - rtn += str.slice(0, tagend); - str = str.slice(tagend); - } else if (str[i] === ':') { - try { - // if replacing :shortname: succeed, exit this block with "continue" - const closeColon = str.indexOf(':', i + 1) + 1; - if (!closeColon) throw null; // no pair of ':' - const lt = str.indexOf('<', i + 1); - if (!(lt === -1 || lt >= closeColon)) throw null; // tag appeared before closing ':' - const shortname = str.slice(i, closeColon); - if (shortname in customEmojis) { - rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`; - str = str.slice(closeColon); - continue; - } - } catch (e) {} - // replacing :shortname: failed - rtn += str.slice(0, i + 1); - str = str.slice(i + 1); - } else { - const [filename, shortCode] = unicodeMapping[match]; - rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="${assetHost}/emoji/${filename}.svg" />`; - str = str.slice(i + match.length); - } - } - return rtn + str; -}; - -export default emojify; - -export const buildCustomEmojis = customEmojis => { - const emojis = []; - - customEmojis.forEach(emoji => { - const shortcode = emoji.get('shortcode'); - const url = emoji.get('url'); - const name = shortcode.replace(':', ''); - - emojis.push({ - id: name, - name, - short_names: [name], - text: '', - emoticons: [], - keywords: [name], - imageUrl: url, - custom: true, - }); - }); - - return emojis; -}; diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js deleted file mode 100644 index 2296497b0..000000000 --- a/app/javascript/mastodon/emojione_light.js +++ /dev/null @@ -1,38 +0,0 @@ -// @preval -// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt - -const emojis = require('./emoji_map.json'); -const { emojiIndex } = require('emoji-mart'); -const excluded = ['®', '©', '™']; -const skins = ['🏻', '🏼', '🏽', '🏾', '🏿']; -const shortcodeMap = {}; - -Object.keys(emojiIndex.emojis).forEach(key => { - shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; -}); - -const stripModifiers = unicode => { - skins.forEach(tone => { - unicode = unicode.replace(tone, ''); - }); - - return unicode; -}; - -Object.keys(emojis).forEach(key => { - if (excluded.includes(key)) { - delete emojis[key]; - return; - } - - const normalizedKey = stripModifiers(key); - let shortcode = shortcodeMap[normalizedKey]; - - if (!shortcode) { - shortcode = shortcodeMap[normalizedKey + '\uFE0F']; - } - - emojis[key] = [emojis[key], shortcode]; -}); - -module.exports.unicodeMapping = emojis; diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index 9e8fea69d..2819ae252 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; -import Link from 'react-router-dom/Link'; +import { Link } from 'react-router-dom'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; const messages = defineMessages({ diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index b85105c53..097dccfb4 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -5,7 +5,6 @@ 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 { debounce } from 'lodash'; import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import Collapsable from '../../../components/collapsable'; @@ -94,9 +93,9 @@ export default class ComposeForm extends ImmutablePureComponent { this.props.onClearSuggestions(); } - onSuggestionsFetchRequested = debounce((token) => { + onSuggestionsFetchRequested = (token) => { this.props.onFetchSuggestions(token); - }, 500, { trailing: true }) + } onSuggestionSelected = (tokenStart, token, value) => { this._restoreCaret = null; diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index 621cc21ce..dffa04ff0 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -1,11 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; -import { Picker, Emoji } from 'emoji-mart'; -import { Overlay } from 'react-overlays'; +import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; +import Overlay from 'react-overlays/lib/Overlay'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; +import { buildCustomEmojis } from '../../emoji/emoji'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -25,9 +26,24 @@ const messages = defineMessages({ }); const assetHost = process.env.CDN_HOST || ''; +let EmojiPicker, Emoji; // load asynchronously + const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +const categoriesSort = [ + 'recent', + 'custom', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', +]; + class ModifierPickerMenu extends React.PureComponent { static propTypes = { @@ -36,9 +52,8 @@ class ModifierPickerMenu extends React.PureComponent { onClose: PropTypes.func.isRequired, }; - handleClick = (e) => { - const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1; - this.props.onSelect(modifier); + handleClick = e => { + this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); } componentWillReceiveProps (nextProps) { @@ -78,12 +93,12 @@ class ModifierPickerMenu extends React.PureComponent { return ( <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> - <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> - <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> </div> ); } @@ -131,6 +146,8 @@ class EmojiPickerMenu extends React.PureComponent { static propTypes = { custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, onClose: PropTypes.func.isRequired, onPick: PropTypes.func.isRequired, style: PropTypes.object, @@ -138,16 +155,20 @@ class EmojiPickerMenu extends React.PureComponent { arrowOffsetLeft: PropTypes.string, arrowOffsetTop: PropTypes.string, intl: PropTypes.object.isRequired, + skinTone: PropTypes.number.isRequired, + onSkinTone: PropTypes.func.isRequired, + autoPlay: PropTypes.bool, }; static defaultProps = { style: {}, + loading: true, placement: 'bottom', + frequentlyUsedEmojis: [], }; state = { modifierOpen: false, - modifier: 1, }; handleDocumentClick = e => { @@ -210,35 +231,43 @@ class EmojiPickerMenu extends React.PureComponent { } handleModifierChange = modifier => { - if (modifier !== this.state.modifier) { - this.setState({ modifier }); - } + this.props.onSkinTone(modifier); } render () { - const { style, intl } = this.props; + const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props; + + if (loading) { + return <div style={{ width: 299 }} />; + } + const title = intl.formatMessage(messages.emoji); - const { modifierOpen, modifier } = this.state; + const { modifierOpen } = this.state; return ( <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> - <Picker + <EmojiPicker perLine={8} emojiSize={22} sheetSize={32} + custom={buildCustomEmojis(custom_emojis, autoPlay)} color='' emoji='' set='twitter' title={title} i18n={this.getI18n()} onClick={this.handleClick} - skin={modifier} + include={categoriesSort} + recent={frequentlyUsedEmojis} + skin={skinTone} + showPreview={false} backgroundImageFn={backgroundImageFn} + emojiTooltip /> <ModifierPicker active={modifierOpen} - modifier={modifier} + modifier={skinTone} onOpen={this.handleModifierOpen} onClose={this.handleModifierClose} onChange={this.handleModifierChange} @@ -254,12 +283,17 @@ export default class EmojiPickerDropdown extends React.PureComponent { static propTypes = { custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + autoPlay: PropTypes.bool, intl: PropTypes.object.isRequired, onPickEmoji: PropTypes.func.isRequired, + onSkinTone: PropTypes.func.isRequired, + skinTone: PropTypes.number.isRequired, }; state = { active: false, + loading: false, }; setRef = (c) => { @@ -268,6 +302,19 @@ export default class EmojiPickerDropdown extends React.PureComponent { onShowDropdown = () => { this.setState({ active: true }); + + if (!EmojiPicker) { + this.setState({ loading: true }); + + EmojiPickerAsync().then(EmojiMart => { + EmojiPicker = EmojiMart.Picker; + Emoji = EmojiMart.Emoji; + + this.setState({ loading: false }); + }).catch(() => { + this.setState({ loading: false }); + }); + } } onHideDropdown = () => { @@ -275,7 +322,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { } onToggle = (e) => { - if (!e.key || e.key === 'Enter') { + if (!this.state.loading && (!e.key || e.key === 'Enter')) { if (this.state.active) { this.onHideDropdown(); } else { @@ -299,15 +346,15 @@ export default class EmojiPickerDropdown extends React.PureComponent { } render () { - const { intl, onPickEmoji } = this.props; + const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; const title = intl.formatMessage(messages.emoji); - const { active } = this.state; + const { active, loading } = this.state; return ( <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> <img - className='emojione' + className={classNames('emojione', { 'pulse-loading': active && loading })} alt='🙂' src={`${assetHost}/emoji/1f602.svg`} /> @@ -316,8 +363,13 @@ export default class EmojiPickerDropdown extends React.PureComponent { <Overlay show={active} placement='bottom' target={this.findTarget}> <EmojiPickerMenu custom_emojis={this.props.custom_emojis} + loading={loading} onClose={this.onHideDropdown} onPick={onPickEmoji} + autoPlay={autoPlay} + onSkinTone={onSkinTone} + skinTone={skinTone} + frequentlyUsedEmojis={frequentlyUsedEmojis} /> </Overlay> </div> diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 0474dfb4e..e38ed38c1 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -2,7 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import { injectIntl, defineMessages } from 'react-intl'; import IconButton from '../../../components/icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'react-motion/lib/Motion'; +import spring from 'react-motion/lib/spring'; import detectPassiveEvents from 'detect-passive-events'; +import classNames from 'classnames'; const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, @@ -16,10 +20,77 @@ const messages = defineMessages({ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, }); -const iconStyle = { - height: null, - lineHeight: '27px', -}; +const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +class PrivacyDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + handleClick = e => { + if (e.key === 'Escape') { + this.props.onClose(); + } else if (!e.key || e.key === 'Enter') { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + this.props.onClose(); + this.props.onChange(value); + } + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + render () { + const { style, items, value } = this.props; + + return ( + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> + {items.map(item => + <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}> + <div className='privacy-dropdown__option__icon'> + <i className={`fa fa-fw fa-${item.icon}`} /> + </div> + + <div className='privacy-dropdown__option__content'> + <strong>{item.text}</strong> + {item.meta} + </div> + </div> + )} + </div> + )} + </Motion> + ); + } + +} @injectIntl export default class PrivacyDropdown extends React.PureComponent { @@ -55,26 +126,30 @@ export default class PrivacyDropdown extends React.PureComponent { handleModalActionClick = (e) => { e.preventDefault(); + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + this.props.onModalClose(); this.props.onChange(value); } - handleClick = (e) => { - if (e.key === 'Escape') { - this.setState({ open: false }); - } else if (!e.key || e.key === 'Enter') { - const value = e.currentTarget.getAttribute('data-index'); - e.preventDefault(); - this.setState({ open: false }); - this.props.onChange(value); + handleKeyDown = e => { + switch(e.key) { + case 'Enter': + this.handleToggle(); + break; + case 'Escape': + this.handleClose(); + break; } } - onGlobalClick = (e) => { - if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { - this.setState({ open: false }); - } + handleClose = () => { + this.setState({ open: false }); + } + + handleChange = value => { + this.props.onChange(value); } componentWillMount () { @@ -88,20 +163,6 @@ export default class PrivacyDropdown extends React.PureComponent { ]; } - componentDidMount () { - window.addEventListener('click', this.onGlobalClick); - window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false); - } - - componentWillUnmount () { - window.removeEventListener('click', this.onGlobalClick); - window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false); - } - - setRef = (c) => { - this.node = c; - } - render () { const { value, intl } = this.props; const { open } = this.state; @@ -109,19 +170,29 @@ export default class PrivacyDropdown extends React.PureComponent { const valueOption = this.options.find(item => item.value === value); return ( - <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> - <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> - <div className='privacy-dropdown__dropdown'> - {open && this.options.map(item => - <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> - <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> - <div className='privacy-dropdown__option__content'> - <strong>{item.text}</strong> - {item.meta} - </div> - </div> - )} + <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}> + <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}> + <IconButton + className='privacy-dropdown__value-icon' + icon={valueOption.icon} + title={intl.formatMessage(messages.change_privacy)} + size={18} + expanded={open} + active={open} + inverted + onClick={this.handleToggle} + style={{ height: null, lineHeight: '27px' }} + /> </div> + + <Overlay show={open} placement='bottom' target={this}> + <PrivacyDropdownMenu + items={this.options} + value={value} + onClose={this.handleClose} + onChange={this.handleChange} + /> + </Overlay> </div> ); } diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 85ef767ab..f57d54618 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -1,11 +1,47 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'react-motion/lib/Motion'; +import spring from 'react-motion/lib/spring'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, }); +class SearchPopout extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + }; + + render () { + const { style } = this.props; + + return ( + <div style={{ ...style, position: 'absolute', width: 285 }}> + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> + + <ul> + <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> + <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> + <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> + <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> + </ul> + + <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' /> + </div> + )} + </Motion> + </div> + ); + } + +} + @injectIntl export default class Search extends React.PureComponent { @@ -19,6 +55,10 @@ export default class Search extends React.PureComponent { intl: PropTypes.object.isRequired, }; + state = { + expanded: false, + }; + handleChange = (e) => { this.props.onChange(e.target.value); } @@ -35,6 +75,8 @@ export default class Search extends React.PureComponent { if (e.key === 'Enter') { e.preventDefault(); this.props.onSubmit(); + } else if (e.key === 'Escape') { + document.querySelector('.ui').parentElement.focus(); } } @@ -43,11 +85,17 @@ export default class Search extends React.PureComponent { } handleFocus = () => { + this.setState({ expanded: true }); this.props.onShow(); } + handleBlur = () => { + this.setState({ expanded: false }); + } + render () { const { intl, value, submitted } = this.props; + const { expanded } = this.state; const hasValue = value.length > 0 || submitted; return ( @@ -62,6 +110,7 @@ export default class Search extends React.PureComponent { onChange={this.handleChange} onKeyUp={this.handleKeyDown} onFocus={this.handleFocus} + onBlur={this.handleBlur} /> </label> @@ -69,6 +118,10 @@ export default class Search extends React.PureComponent { <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> </div> + + <Overlay show={expanded && !hasValue} placement='bottom' target={this}> + <SearchPopout /> + </Overlay> </div> ); } diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index cae4ca412..a3e68643f 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../../containers/account_container'; import StatusContainer from '../../../../glitch/components/status/container'; -import Link from 'react-router-dom/Link'; +import { Link } from 'react-router-dom'; import ImmutablePureComponent from 'react-immutable-pure-component'; export default class SearchResults extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js new file mode 100644 index 000000000..cd9e08360 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -0,0 +1,96 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from '../../../components/icon_button'; +import Motion from 'react-motion/lib/Motion'; +import spring from 'react-motion/lib/spring'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; +import classNames from 'classnames'; + +const messages = defineMessages({ + undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, + description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, +}); + +@injectIntl +export default class Upload extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onUndo: PropTypes.func.isRequired, + onDescriptionChange: PropTypes.func.isRequired, + }; + + state = { + hovered: false, + focused: false, + dirtyDescription: null, + }; + + handleUndoClick = () => { + this.props.onUndo(this.props.media.get('id')); + } + + handleInputChange = e => { + this.setState({ dirtyDescription: e.target.value }); + } + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + handleInputFocus = () => { + this.setState({ focused: true }); + } + + handleInputBlur = () => { + const { dirtyDescription } = this.state; + + this.setState({ focused: false, dirtyDescription: null }); + + if (dirtyDescription !== null) { + this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); + } + } + + render () { + const { intl, media } = this.props; + const active = this.state.hovered || this.state.focused; + const description = this.state.dirtyDescription || media.get('description') || ''; + + return ( + <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> + {({ scale }) => ( + <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> + <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> + + <div className={classNames('compose-form__upload-description', { active })}> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> + + <input + placeholder={intl.formatMessage(messages.description)} + type='text' + value={description} + maxLength={420} + onFocus={this.handleInputFocus} + onChange={this.handleInputChange} + onBlur={this.handleInputBlur} + /> + </label> + </div> + </div> + )} + </Motion> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js index cf2d2658a..b7f112205 100644 --- a/app/javascript/mastodon/features/compose/components/upload_form.js +++ b/app/javascript/mastodon/features/compose/components/upload_form.js @@ -1,49 +1,27 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; -import IconButton from '../../../components/icon_button'; -import { defineMessages, injectIntl } from 'react-intl'; import UploadProgressContainer from '../containers/upload_progress_container'; -import Motion from 'react-motion/lib/Motion'; -import spring from 'react-motion/lib/spring'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import UploadContainer from '../containers/upload_container'; -const messages = defineMessages({ - undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, -}); - -@injectIntl -export default class UploadForm extends React.PureComponent { +export default class UploadForm extends ImmutablePureComponent { static propTypes = { - media: ImmutablePropTypes.list.isRequired, - onRemoveFile: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, + mediaIds: ImmutablePropTypes.list.isRequired, }; - onRemoveFile = (e) => { - const id = e.currentTarget.parentElement.getAttribute('data-id'); - this.props.onRemoveFile(id); - } - render () { - const { intl, media } = this.props; - - const uploads = media.map(attachment => - <div className='compose-form__upload' key={attachment.get('id')}> - <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> - {({ scale }) => - <div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}> - <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} /> - </div> - } - </Motion> - </div> - ); + const { mediaIds } = this.props; return ( <div className='compose-form__upload-wrapper'> <UploadProgressContainer /> - <div className='compose-form__uploads-wrapper'>{uploads}</div> + + <div className='compose-form__uploads-wrapper'> + {mediaIds.map(id => ( + <UploadContainer id={id} key={id} /> + ))} + </div> </div> ); } diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js index 75f36b840..a0814e984 100644 --- a/app/javascript/mastodon/features/compose/components/warning.js +++ b/app/javascript/mastodon/features/compose/components/warning.js @@ -1,5 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Motion from 'react-motion/lib/Motion'; +import spring from 'react-motion/lib/spring'; export default class Warning extends React.PureComponent { @@ -11,9 +13,13 @@ export default class Warning extends React.PureComponent { const { message } = this.props; return ( - <div className='compose-form__warning'> - {message} - </div> + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + {message} + </div> + )} + </Motion> ); } diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js index 7a8026bbc..71944128c 100644 --- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js @@ -1,8 +1,83 @@ import { connect } from 'react-redux'; import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; +import { changeSetting } from '../../../actions/settings'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; +import { useEmoji } from '../../../actions/emojis'; + +const perLine = 8; +const lines = 2; + +const DEFAULTS = [ + '+1', + 'grinning', + 'kissing_heart', + 'heart_eyes', + 'laughing', + 'stuck_out_tongue_winking_eye', + 'sweat_smile', + 'joy', + 'yum', + 'disappointed', + 'thinking_face', + 'weary', + 'sob', + 'sunglasses', + 'heart', + 'ok_hand', +]; + +const getFrequentlyUsedEmojis = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), +], emojiCounters => { + let emojis = emojiCounters + .keySeq() + .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) + .reverse() + .slice(0, perLine * lines) + .toArray(); + + if (emojis.length < DEFAULTS.length) { + emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length)); + } + + return emojis; +}); + +const getCustomEmojis = createSelector([ + state => state.get('custom_emojis'), +], emojis => emojis.sort((a, b) => { + const aShort = a.get('shortcode').toLowerCase(); + const bShort = b.get('shortcode').toLowerCase(); + + if (aShort < bShort) { + return -1; + } else if (aShort > bShort ) { + return 1; + } else { + return 0; + } +})); const mapStateToProps = state => ({ - custom_emojis: state.get('custom_emojis'), + custom_emojis: getCustomEmojis(state), + autoPlay: state.getIn(['meta', 'auto_play_gif']), + skinTone: state.getIn(['settings', 'skinTone']), + frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), +}); + +const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ + onSkinTone: skinTone => { + dispatch(changeSetting(['skinTone'], skinTone)); + }, + + onPickEmoji: emoji => { + dispatch(useEmoji(emoji)); + + if (onPickEmoji) { + onPickEmoji(emoji); + } + }, }); -export default connect(mapStateToProps)(EmojiPickerDropdown); +export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown); diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js new file mode 100644 index 000000000..ca9c3b704 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/upload_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import Upload from '../components/upload'; +import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), +}); + +const mapDispatchToProps = dispatch => ({ + + onUndo: id => { + dispatch(undoUploadCompose(id)); + }, + + onDescriptionChange: (id, description) => { + dispatch(changeUploadCompose(id, description)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Upload); diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js index 4612599f1..a6798bf51 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js @@ -1,17 +1,8 @@ import { connect } from 'react-redux'; import UploadForm from '../components/upload_form'; -import { undoUploadCompose } from '../../../actions/compose'; const mapStateToProps = state => ({ - media: state.getIn(['compose', 'media_attachments']), + mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), }); -const mapDispatchToProps = dispatch => ({ - - onRemoveFile (media_id) { - dispatch(undoUploadCompose(media_id)); - }, - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(UploadForm); +export default connect(mapStateToProps)(UploadForm); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index f0bce1e40..9068648bd 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -7,7 +7,7 @@ import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; import { openModal } from '../../actions/modal'; import { changeLocalSetting } from '../../../glitch/actions/local_settings'; -import Link from 'react-router-dom/Link'; +import { Link } from 'react-router-dom'; import { injectIntl, defineMessages } from 'react-intl'; import SearchContainer from './containers/search_container'; import Motion from 'react-motion/lib/Motion'; diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js new file mode 100644 index 000000000..b70fc2b37 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -0,0 +1,77 @@ +import unicodeMapping from './emoji_unicode_mapping_light'; +import Trie from 'substring-trie'; + +const trie = new Trie(Object.keys(unicodeMapping)); + +const assetHost = process.env.CDN_HOST || ''; + +let allowAnimations = false; + +const emojify = (str, customEmojis = {}) => { + let rtn = ''; + for (;;) { + let match, i = 0, tag; + while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) { + i += str.codePointAt(i) < 65536 ? 1 : 2; + } + let rend, replacement = ''; + if (i === str.length) { + break; + } else if (str[i] === ':') { + if (!(() => { + rend = str.indexOf(':', i + 1) + 1; + if (!rend) return false; // no pair of ':' + const lt = str.indexOf('<', i + 1); + if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':' + const shortname = str.slice(i, rend); + // now got a replacee as ':shortname:' + // if you want additional emoji handler, add statements below which set replacement and return true. + if (shortname in customEmojis) { + const filename = allowAnimations ? customEmojis[shortname].url : customEmojis[shortname].static_url; + replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`; + return true; + } + return false; + })()) rend = ++i; + } else if (tag >= 0) { // <, & + rend = str.indexOf('>;'[tag], i + 1) + 1; + if (!rend) break; + i = rend; + } else { // matched to unicode emoji + const { filename, shortCode } = unicodeMapping[match]; + const title = shortCode ? `:${shortCode}:` : ''; + replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; + rend = i + match.length; + } + rtn += str.slice(0, i) + replacement; + str = str.slice(rend); + } + return rtn + str; +}; + +export default emojify; + +export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => { + const emojis = []; + + allowAnimations = overrideAllowAnimations; + + customEmojis.forEach(emoji => { + const shortcode = emoji.get('shortcode'); + const url = allowAnimations ? emoji.get('url') : emoji.get('static_url'); + const name = shortcode.replace(':', ''); + + emojis.push({ + id: name, + name, + short_names: [name], + text: '', + emoticons: [], + keywords: [name], + imageUrl: url, + custom: true, + }); + }); + + return emojis; +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js new file mode 100644 index 000000000..3bd89cf3b --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js @@ -0,0 +1,92 @@ +// @preval +// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt +// This file contains the compressed version of the emoji data from +// both emoji_map.json and from emoji-mart's emojiIndex and data objects. +// It's designed to be emitted in an array format to take up less space +// over the wire. + +const { unicodeToFilename } = require('./unicode_to_filename'); +const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); +const emojiMap = require('./emoji_map.json'); +const { emojiIndex } = require('emoji-mart'); +const emojiMartData = require('emoji-mart/dist/data').default; +const excluded = ['®', '©', '™']; +const skins = ['🏻', '🏼', '🏽', '🏾', '🏿']; +const shortcodeMap = {}; + +const shortCodesToEmojiData = {}; +const emojisWithoutShortCodes = []; + +Object.keys(emojiIndex.emojis).forEach(key => { + shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; +}); + +const stripModifiers = unicode => { + skins.forEach(tone => { + unicode = unicode.replace(tone, ''); + }); + + return unicode; +}; + +Object.keys(emojiMap).forEach(key => { + if (excluded.includes(key)) { + delete emojiMap[key]; + return; + } + + const normalizedKey = stripModifiers(key); + let shortcode = shortcodeMap[normalizedKey]; + + if (!shortcode) { + shortcode = shortcodeMap[normalizedKey + '\uFE0F']; + } + + const filename = emojiMap[key]; + + const filenameData = [key]; + + if (unicodeToFilename(key) !== filename) { + // filename can't be derived using unicodeToFilename + filenameData.push(filename); + } + + if (typeof shortcode === 'undefined') { + emojisWithoutShortCodes.push(filenameData); + } else { + if (!Array.isArray(shortCodesToEmojiData[shortcode])) { + shortCodesToEmojiData[shortcode] = [[]]; + } + shortCodesToEmojiData[shortcode][0].push(filenameData); + } +}); + +Object.keys(emojiIndex.emojis).forEach(key => { + const { native } = emojiIndex.emojis[key]; + const { short_names, search, unified } = emojiMartData.emojis[key]; + if (short_names[0] !== key) { + throw new Error('The compresser expects the first short_code to be the ' + + 'key. It may need to be rewritten if the emoji change such that this ' + + 'is no longer the case.'); + } + + short_names.splice(0, 1); // first short name can be inferred from the key + + const searchData = [native, short_names, search]; + if (unicodeToUnifiedName(native) !== unified) { + // unified name can't be derived from unicodeToUnifiedName + searchData.push(unified); + } + + shortCodesToEmojiData[key].push(searchData); +}); + +// JSON.parse/stringify is to emulate what @preval is doing and avoid any +// inconsistent behavior in dev mode +module.exports = JSON.parse(JSON.stringify([ + shortCodesToEmojiData, + emojiMartData.skins, + emojiMartData.categories, + emojiMartData.short_names, + emojisWithoutShortCodes, +])); diff --git a/app/javascript/mastodon/emoji_map.json b/app/javascript/mastodon/features/emoji/emoji_map.json index 13753ba84..13753ba84 100644 --- a/app/javascript/mastodon/emoji_map.json +++ b/app/javascript/mastodon/features/emoji/emoji_map.json diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js new file mode 100644 index 000000000..45086fc4c --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js @@ -0,0 +1,41 @@ +// The output of this module is designed to mimic emoji-mart's +// "data" object, such that we can use it for a light version of emoji-mart's +// emojiIndex.search functionality. +const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); +const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed'); + +const emojis = {}; + +// decompress +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + let [ + filenameData, // eslint-disable-line no-unused-vars + searchData, + ] = shortCodesToEmojiData[shortCode]; + let [ + native, + short_names, + search, + unified, + ] = searchData; + + if (!unified) { + // unified name can be derived from unicodeToUnifiedName + unified = unicodeToUnifiedName(native); + } + + short_names = [shortCode].concat(short_names); + emojis[shortCode] = { + native, + search, + short_names, + unified, + }; +}); + +module.exports = { + emojis, + skins, + categories, + short_names, +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js new file mode 100644 index 000000000..5755bf1c4 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js @@ -0,0 +1,157 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js + +import data from './emoji_mart_data_light'; +import { getData, getSanitizedData, intersect } from './emoji_utils'; + +let originalPool = {}; +let index = {}; +let emojisList = {}; +let emoticonsList = {}; + +for (let emoji in data.emojis) { + let emojiData = data.emojis[emoji]; + let { short_names, emoticons } = emojiData; + let id = short_names[0]; + + if (emoticons) { + emoticons.forEach(emoticon => { + if (emoticonsList[emoticon]) { + return; + } + + emoticonsList[emoticon] = id; + }); + } + + emojisList[id] = getSanitizedData(id); + originalPool[id] = emojiData; +} + +function addCustomToPool(custom, pool) { + custom.forEach((emoji) => { + let emojiId = emoji.id || emoji.short_names[0]; + + if (emojiId && !pool[emojiId]) { + pool[emojiId] = getData(emoji); + emojisList[emojiId] = getSanitizedData(emoji); + } + }); +} + +function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) { + addCustomToPool(custom, originalPool); + + maxResults = maxResults || 75; + include = include || []; + exclude = exclude || []; + + let results = null, + pool = originalPool; + + if (value.length) { + if (value === '-' || value === '-1') { + return [emojisList['-1']]; + } + + let values = value.toLowerCase().split(/[\s|,|\-|_]+/), + allResults = []; + + if (values.length > 2) { + values = [values[0], values[1]]; + } + + if (include.length || exclude.length) { + pool = {}; + + data.categories.forEach(category => { + let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; + let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; + if (!isIncluded || isExcluded) { + return; + } + + category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]); + }); + + if (custom.length) { + let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true; + let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false; + if (customIsIncluded && !customIsExcluded) { + addCustomToPool(custom, pool); + } + } + } + + allResults = values.map((value) => { + let aPool = pool, + aIndex = index, + length = 0; + + for (let charIndex = 0; charIndex < value.length; charIndex++) { + const char = value[charIndex]; + length++; + + aIndex[char] = aIndex[char] || {}; + aIndex = aIndex[char]; + + if (!aIndex.results) { + let scores = {}; + + aIndex.results = []; + aIndex.pool = {}; + + for (let id in aPool) { + let emoji = aPool[id], + { search } = emoji, + sub = value.substr(0, length), + subIndex = search.indexOf(sub); + + if (subIndex !== -1) { + let score = subIndex + 1; + if (sub === id) score = 0; + + aIndex.results.push(emojisList[id]); + aIndex.pool[id] = emoji; + + scores[id] = score; + } + } + + aIndex.results.sort((a, b) => { + let aScore = scores[a.id], + bScore = scores[b.id]; + + return aScore - bScore; + }); + } + + aPool = aIndex.pool; + } + + return aIndex.results; + }).filter(a => a); + + if (allResults.length > 1) { + results = intersect.apply(null, allResults); + } else if (allResults.length) { + results = allResults[0]; + } else { + results = []; + } + } + + if (results) { + if (emojisToShowFilter) { + results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified)); + } + + if (results && results.length > maxResults) { + results = results.slice(0, maxResults); + } + } + + return results; +} + +export { search }; diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.js b/app/javascript/mastodon/features/emoji/emoji_picker.js new file mode 100644 index 000000000..7e145381e --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_picker.js @@ -0,0 +1,7 @@ +import Picker from 'emoji-mart/dist-es/components/picker'; +import Emoji from 'emoji-mart/dist-es/components/emoji'; + +export { + Picker, + Emoji, +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js new file mode 100644 index 000000000..918684c31 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js @@ -0,0 +1,35 @@ +// A mapping of unicode strings to an object containing the filename +// (i.e. the svg filename) and a shortCode intended to be shown +// as a "title" attribute in an HTML element (aka tooltip). + +const [ + shortCodesToEmojiData, + skins, // eslint-disable-line no-unused-vars + categories, // eslint-disable-line no-unused-vars + short_names, // eslint-disable-line no-unused-vars + emojisWithoutShortCodes, +] = require('./emoji_compressed'); +const { unicodeToFilename } = require('./unicode_to_filename'); + +// decompress +const unicodeMapping = {}; + +function processEmojiMapData(emojiMapData, shortCode) { + let [ native, filename ] = emojiMapData; + if (!filename) { + // filename name can be derived from unicodeToFilename + filename = unicodeToFilename(native); + } + unicodeMapping[native] = { + shortCode: shortCode, + filename: filename, + }; +} + +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + let [ filenameData ] = shortCodesToEmojiData[shortCode]; + filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode)); +}); +emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData)); + +module.exports = unicodeMapping; diff --git a/app/javascript/mastodon/features/emoji/emoji_utils.js b/app/javascript/mastodon/features/emoji/emoji_utils.js new file mode 100644 index 000000000..dbf725c1f --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_utils.js @@ -0,0 +1,258 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js + +import data from './emoji_mart_data_light'; + +const buildSearch = (data) => { + const search = []; + + let addToSearch = (strings, split) => { + if (!strings) { + return; + } + + (Array.isArray(strings) ? strings : [strings]).forEach((string) => { + (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { + s = s.toLowerCase(); + + if (search.indexOf(s) === -1) { + search.push(s); + } + }); + }); + }; + + addToSearch(data.short_names, true); + addToSearch(data.name, true); + addToSearch(data.keywords, false); + addToSearch(data.emoticons, false); + + return search.join(','); +}; + +const _String = String; + +const stringFromCodePoint = _String.fromCodePoint || function () { + let MAX_SIZE = 0x4000; + let codeUnits = []; + let highSurrogate; + let lowSurrogate; + let index = -1; + let length = arguments.length; + if (!length) { + return ''; + } + let result = ''; + while (++index < length) { + let codePoint = Number(arguments[index]); + if ( + !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity` + codePoint < 0 || // not a valid Unicode code point + codePoint > 0x10FFFF || // not a valid Unicode code point + Math.floor(codePoint) !== codePoint // not an integer + ) { + throw RangeError('Invalid code point: ' + codePoint); + } + if (codePoint <= 0xFFFF) { // BMP code point + codeUnits.push(codePoint); + } else { // Astral code point; split in surrogate halves + // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + codePoint -= 0x10000; + highSurrogate = (codePoint >> 10) + 0xD800; + lowSurrogate = (codePoint % 0x400) + 0xDC00; + codeUnits.push(highSurrogate, lowSurrogate); + } + if (index + 1 === length || codeUnits.length > MAX_SIZE) { + result += String.fromCharCode.apply(null, codeUnits); + codeUnits.length = 0; + } + } + return result; +}; + + +const _JSON = JSON; + +const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/; +const SKINS = [ + '1F3FA', '1F3FB', '1F3FC', + '1F3FD', '1F3FE', '1F3FF', +]; + +function unifiedToNative(unified) { + let unicodes = unified.split('-'), + codePoints = unicodes.map((u) => `0x${u}`); + + return stringFromCodePoint.apply(null, codePoints); +} + +function sanitize(emoji) { + let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, + id = emoji.id || short_names[0], + colons = `:${id}:`; + + if (custom) { + return { + id, + name, + colons, + emoticons, + custom, + imageUrl, + }; + } + + if (skin_tone) { + colons += `:skin-tone-${skin_tone}:`; + } + + return { + id, + name, + colons, + emoticons, + unified: unified.toLowerCase(), + skin: skin_tone || (skin_variations ? 1 : null), + native: unifiedToNative(unified), + }; +} + +function getSanitizedData() { + return sanitize(getData(...arguments)); +} + +function getData(emoji, skin, set) { + let emojiData = {}; + + if (typeof emoji === 'string') { + let matches = emoji.match(COLONS_REGEX); + + if (matches) { + emoji = matches[1]; + + if (matches[2]) { + skin = parseInt(matches[2]); + } + } + + if (data.short_names.hasOwnProperty(emoji)) { + emoji = data.short_names[emoji]; + } + + if (data.emojis.hasOwnProperty(emoji)) { + emojiData = data.emojis[emoji]; + } + } else if (emoji.id) { + if (data.short_names.hasOwnProperty(emoji.id)) { + emoji.id = data.short_names[emoji.id]; + } + + if (data.emojis.hasOwnProperty(emoji.id)) { + emojiData = data.emojis[emoji.id]; + skin = skin || emoji.skin; + } + } + + if (!Object.keys(emojiData).length) { + emojiData = emoji; + emojiData.custom = true; + + if (!emojiData.search) { + emojiData.search = buildSearch(emoji); + } + } + + emojiData.emoticons = emojiData.emoticons || []; + emojiData.variations = emojiData.variations || []; + + if (emojiData.skin_variations && skin > 1 && set) { + emojiData = JSON.parse(_JSON.stringify(emojiData)); + + let skinKey = SKINS[skin - 1], + variationData = emojiData.skin_variations[skinKey]; + + if (!variationData.variations && emojiData.variations) { + delete emojiData.variations; + } + + if (variationData[`has_img_${set}`]) { + emojiData.skin_tone = skin; + + for (let k in variationData) { + let v = variationData[k]; + emojiData[k] = v; + } + } + } + + if (emojiData.variations && emojiData.variations.length) { + emojiData = JSON.parse(_JSON.stringify(emojiData)); + emojiData.unified = emojiData.variations.shift(); + } + + return emojiData; +} + +function uniq(arr) { + return arr.reduce((acc, item) => { + if (acc.indexOf(item) === -1) { + acc.push(item); + } + return acc; + }, []); +} + +function intersect(a, b) { + const uniqA = uniq(a); + const uniqB = uniq(b); + + return uniqA.filter(item => uniqB.indexOf(item) >= 0); +} + +function deepMerge(a, b) { + let o = {}; + + for (let key in a) { + let originalValue = a[key], + value = originalValue; + + if (b.hasOwnProperty(key)) { + value = b[key]; + } + + if (typeof value === 'object') { + value = deepMerge(originalValue, value); + } + + o[key] = value; + } + + return o; +} + +// https://github.com/sonicdoe/measure-scrollbar +function measureScrollbar() { + const div = document.createElement('div'); + + div.style.width = '100px'; + div.style.height = '100px'; + div.style.overflow = 'scroll'; + div.style.position = 'absolute'; + div.style.top = '-9999px'; + + document.body.appendChild(div); + const scrollbarWidth = div.offsetWidth - div.clientWidth; + document.body.removeChild(div); + + return scrollbarWidth; +} + +export { + getData, + getSanitizedData, + uniq, + intersect, + deepMerge, + unifiedToNative, + measureScrollbar, +}; diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename.js b/app/javascript/mastodon/features/emoji/unicode_to_filename.js new file mode 100644 index 000000000..c75c4cd7d --- /dev/null +++ b/app/javascript/mastodon/features/emoji/unicode_to_filename.js @@ -0,0 +1,26 @@ +// taken from: +// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866 +exports.unicodeToFilename = (str) => { + let result = ''; + let charCode = 0; + let p = 0; + let i = 0; + while (i < str.length) { + charCode = str.charCodeAt(i++); + if (p) { + if (result.length > 0) { + result += '-'; + } + result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16); + p = 0; + } else if (0xD800 <= charCode && charCode <= 0xDBFF) { + p = charCode; + } else { + if (result.length > 0) { + result += '-'; + } + result += charCode.toString(16); + } + } + return result; +}; diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js new file mode 100644 index 000000000..808ac197e --- /dev/null +++ b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js @@ -0,0 +1,17 @@ +function padLeft(str, num) { + while (str.length < num) { + str = '0' + str; + } + return str; +} + +exports.unicodeToUnifiedName = (str) => { + let output = ''; + for (let i = 0; i < str.length; i += 2) { + if (i > 0) { + output += '-'; + } + output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4); + } + return output; +}; diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 1bb4bd279..8d74783a3 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import Link from 'react-router-dom/Link'; +import { Link } from 'react-router-dom'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index b52c3c934..903526822 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -9,61 +9,126 @@ import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; export default class Notification extends ImmutablePureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { notification: ImmutablePropTypes.map.isRequired, hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, }; + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + const { notification } = this.props; + + if (notification.get('status')) { + this.context.router.history.push(`/statuses/${notification.get('status')}`); + } else { + this.handleOpenProfile(); + } + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + renderFollow (account, link) { return ( - <div className='notification notification-follow'> - <div className='notification__message'> - <div className='notification__favourite-icon-wrapper'> - <i className='fa fa-fw fa-user-plus' /> + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-follow focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-user-plus' /> + </div> + + <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> </div> - <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> + <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> </div> - - <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> - </div> + </HotKeys> ); } renderMention (notification) { - return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />; + return ( + <StatusContainer + id={notification.get('status')} + withDismiss + hidden={this.props.hidden} + onMoveDown={this.handleMoveDown} + onMoveUp={this.handleMoveUp} + /> + ); } renderFavourite (notification, link) { return ( - <div className='notification notification-favourite'> - <div className='notification__message'> - <div className='notification__favourite-icon-wrapper'> - <i className='fa fa-fw fa-star star-icon' /> + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-favourite focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-star star-icon' /> + </div> + <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> </div> - <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> - </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> - </div> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> + </div> + </HotKeys> ); } renderReblog (notification, link) { return ( - <div className='notification notification-reblog'> - <div className='notification__message'> - <div className='notification__favourite-icon-wrapper'> - <i className='fa fa-fw fa-retweet' /> + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-reblog focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-retweet' /> + </div> + <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> - <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> - </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> - </div> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> + </div> + </HotKeys> ); } diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 1f98a66d2..fd16c4331 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { makeGetNotification } from '../../../selectors'; import Notification from '../components/notification'; +import { mentionCompose } from '../../../actions/compose'; const makeMapStateToProps = () => { const getNotification = makeGetNotification(); @@ -15,4 +16,10 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -export default connect(makeMapStateToProps)(Notification); +const mapDispatchToProps = dispatch => ({ + onMention: (account, router) => { + dispatch(mentionCompose(account, router)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 0ed940c6d..9c6802482 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -103,6 +103,24 @@ export default class Notifications extends React.PureComponent { this.column = c; } + handleMoveUp = id => { + const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1; + this._selectChild(elementIndex); + } + + handleMoveDown = id => { + const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1; + this._selectChild(elementIndex); + } + + _selectChild (index) { + const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + element.focus(); + } + } + render () { const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; @@ -113,7 +131,15 @@ export default class Notifications extends React.PureComponent { if (isLoading && this.scrollableContent) { scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { - scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />); + scrollableContent = notifications.map((item) => ( + <NotificationContainer + key={item.get('id')} + notification={item} + accountId={item.get('account')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); } else { scrollableContent = null; } diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js new file mode 100644 index 000000000..f15fbb2f4 --- /dev/null +++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../../ui/containers/status_list_container'; +import { + refreshHashtagTimeline, + expandHashtagTimeline, +} from '../../../actions/timelines'; +import Column from '../../../components/column'; +import ColumnHeader from '../../../components/column_header'; + +@connect() +export default class HashtagTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + hashtag: PropTypes.string.isRequired, + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + componentDidMount () { + const { dispatch, hashtag } = this.props; + + dispatch(refreshHashtagTimeline(hashtag)); + + this.polling = setInterval(() => { + dispatch(refreshHashtagTimeline(hashtag)); + }, 10000); + } + + componentWillUnmount () { + if (typeof this.polling !== 'undefined') { + clearInterval(this.polling); + this.polling = null; + } + } + + handleLoadMore = () => { + this.props.dispatch(expandHashtagTimeline(this.props.hashtag)); + } + + render () { + const { hashtag } = this.props; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='hashtag' + title={hashtag} + onClick={this.handleHeaderClick} + /> + + <StatusListContainer + trackScroll={false} + scrollKey='standalone_hashtag_timeline' + timelineId={`hashtag:${hashtag}`} + loadMore={this.handleLoadMore} + /> + </Column> + ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 41c4300d3..bb83374b9 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -30,6 +30,10 @@ export default class Card extends React.PureComponent { maxDescription: 50, }; + state = { + width: 0, + }; + renderLink () { const { card, maxDescription } = this.props; @@ -75,14 +79,25 @@ export default class Card extends React.PureComponent { ); } + setRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + renderVideo () { - const { card } = this.props; - const content = { __html: card.get('html') }; + const { card } = this.props; + const content = { __html: card.get('html') }; + const { width } = this.state; + const ratio = card.get('width') / card.get('height'); + const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); return ( <div + ref={this.setRef} className='status-card-video' dangerouslySetInnerHTML={content} + style={{ height }} /> ); } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 232eccf70..816f83e45 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -7,7 +7,7 @@ import StatusContent from '../../../../glitch/components/status/content'; import StatusGallery from '../../../../glitch/components/status/gallery'; import StatusPlayer from '../../../../glitch/components/status/player'; import AttachmentList from '../../../components/attachment_list'; -import Link from 'react-router-dom/Link'; +import { Link } from 'react-router-dom'; import { FormattedDate, FormattedNumber } from 'react-intl'; import CardContainer from '../containers/card_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index fc45d5f21..fff5f529c 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -28,6 +28,7 @@ import StatusContainer from '../../../glitch/components/status/container'; import { openModal } from '../../actions/modal'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -153,8 +154,100 @@ export default class Status extends ImmutablePureComponent { this.props.dispatch(openModal('EMBED', { url: status.get('url') })); } + handleHotkeyMoveUp = () => { + this.handleMoveUp(this.props.status.get('id')); + } + + handleHotkeyMoveDown = () => { + this.handleMoveDown(this.props.status.get('id')); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.handleReplyClick(this.props.status); + } + + handleHotkeyFavourite = () => { + this.handleFavouriteClick(this.props.status); + } + + handleHotkeyBoost = () => { + this.handleReblogClick(this.props.status); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.handleMentionClick(this.props.status); + } + + handleHotkeyOpenProfile = () => { + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + + handleMoveUp = id => { + const { status, ancestorsIds, descendantsIds } = this.props; + + if (id === status.get('id')) { + this._selectChild(ancestorsIds.size - 1); + } else { + let index = ancestorsIds.indexOf(id); + + if (index === -1) { + index = descendantsIds.indexOf(id); + this._selectChild(ancestorsIds.size + index); + } else { + this._selectChild(index - 1); + } + } + } + + handleMoveDown = id => { + const { status, ancestorsIds, descendantsIds } = this.props; + + if (id === status.get('id')) { + this._selectChild(ancestorsIds.size + 1); + } else { + let index = ancestorsIds.indexOf(id); + + if (index === -1) { + index = descendantsIds.indexOf(id); + this._selectChild(ancestorsIds.size + index + 2); + } else { + this._selectChild(index + 1); + } + } + } + + _selectChild (index) { + const element = this.node.querySelectorAll('.focusable')[index]; + + if (element) { + element.focus(); + } + } + renderChildren (list) { - return list.map(id => <StatusContainer key={id} id={id} />); + return list.map(id => ( + <StatusContainer + key={id} + id={id} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); + } + + setRef = c => { + this.node = c; + } + + componentDidUpdate () { + const { status, ancestorsIds } = this.props; + + if (status && ancestorsIds && ancestorsIds.size > 0) { + const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size]; + element.scrollIntoView(); + } } render () { @@ -178,35 +271,49 @@ export default class Status extends ImmutablePureComponent { descendants = <div>{this.renderChildren(descendantsIds)}</div>; } + const handlers = { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + openProfile: this.handleHotkeyOpenProfile, + }; + return ( <Column> <ColumnBackButton /> <ScrollContainer scrollKey='thread'> - <div className='scrollable detailed-status__wrapper'> + <div className='scrollable detailed-status__wrapper' ref={this.setRef}> {ancestors} - <DetailedStatus - status={status} - settings={settings} - autoPlayGif={autoPlayGif} - me={me} - onOpenVideo={this.handleOpenVideo} - onOpenMedia={this.handleOpenMedia} - /> - - <ActionBar - status={status} - me={me} - onReply={this.handleReplyClick} - onFavourite={this.handleFavouriteClick} - onReblog={this.handleReblogClick} - onDelete={this.handleDeleteClick} - onMention={this.handleMentionClick} - onReport={this.handleReport} - onPin={this.handlePin} - onEmbed={this.handleEmbed} - /> + <HotKeys handlers={handlers}> + <div className='focusable' tabIndex='0'> + <DetailedStatus + status={status} + settings={settings} + autoPlayGif={autoPlayGif} + me={me} + onOpenVideo={this.handleOpenVideo} + onOpenMedia={this.handleOpenMedia} + /> + + <ActionBar + status={status} + me={me} + onReply={this.handleReplyClick} + onFavourite={this.handleFavouriteClick} + onReblog={this.handleReblogClick} + onDelete={this.handleDeleteClick} + onMention={this.handleMentionClick} + onReport={this.handleReport} + onPin={this.handlePin} + onEmbed={this.handleEmbed} + /> + </div> + </HotKeys> {descendants} </div> diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js index 62aab9a23..b845d1895 100644 --- a/app/javascript/mastodon/features/ui/components/column_link.js +++ b/app/javascript/mastodon/features/ui/components/column_link.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Link from 'react-router-dom/Link'; +import { Link } from 'react-router-dom'; const ColumnLink = ({ icon, text, to, onClick, href, method }) => { if (href) { diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index 828419d5a..f41a83089 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -29,7 +29,7 @@ export default class MediaModal extends ImmutablePureComponent { }; handleSwipe = (index) => { - this.setState({ index: (index) % this.props.media.size }); + this.setState({ index: index % this.props.media.size }); } handleNextClick = () => { @@ -40,6 +40,11 @@ export default class MediaModal extends ImmutablePureComponent { this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); } + handleChangeIndex = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); + this.setState({ index: index % this.props.media.size }); + } + handleKeyUp = (e) => { switch(e.key) { case 'ArrowLeft': @@ -67,33 +72,51 @@ export default class MediaModal extends ImmutablePureComponent { const { media, intl, onClose } = this.props; const index = this.getIndex(); + let pagination = []; const leftNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; + if (media.size > 1) { + pagination = media.map((item, i) => { + const classes = ['media-modal__button']; + if (i === index) { + classes.push('media-modal__button--active'); + } + return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>); + }); + } + const content = media.map((image) => { const width = image.getIn(['meta', 'original', 'width']) || null; const height = image.getIn(['meta', 'original', 'height']) || null; if (image.get('type') === 'image') { - return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />; + return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />; } else if (image.get('type') === 'gifv') { - return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />; + return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />; } return null; }).toArray(); + const containerStyle = { + alignItems: 'center', // center vertically + }; + return ( <div className='modal-root__modal media-modal'> {leftNav} <div className='media-modal__content'> <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> - <ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight> + <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}> {content} </ReactSwipeableViews> </div> + <ul className='media-modal__pagination'> + {pagination} + </ul> {rightNav} </div> diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 6347c4b22..88a4d0a59 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -1,7 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; import BundleContainer from '../containers/bundle_container'; import BundleModalError from './bundle_modal_error'; import ModalLoading from './modal_loading'; @@ -39,6 +37,10 @@ export default class ModalRoot extends React.PureComponent { onClose: PropTypes.func.isRequired, }; + state = { + revealed: false, + }; + handleKeyUp = (e) => { if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) && !!this.props.type) { @@ -55,6 +57,8 @@ export default class ModalRoot extends React.PureComponent { this.activeElement = document.activeElement; this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + } else if (!nextProps.type) { + this.setState({ revealed: false }); } } @@ -64,6 +68,11 @@ export default class ModalRoot extends React.PureComponent { this.activeElement.focus(); this.activeElement = null; } + if (this.props.type) { + requestAnimationFrame(() => { + this.setState({ revealed: true }); + }); + } } componentWillUnmount () { @@ -78,14 +87,6 @@ export default class ModalRoot extends React.PureComponent { this.node = ref; } - willEnter () { - return { opacity: 0, scale: 0.98 }; - } - - willLeave () { - return { opacity: spring(0), scale: spring(0.98) }; - } - renderLoading = modalId => () => { return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; } @@ -98,38 +99,30 @@ export default class ModalRoot extends React.PureComponent { render () { const { type, props, onClose } = this.props; + const { revealed } = this.state; const visible = !!type; - const items = []; - if (visible) { - items.push({ - key: type, - data: { type, props }, - style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }, - }); + if (!visible) { + return ( + <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> + ); } return ( - <TransitionMotion - styles={items} - willEnter={this.willEnter} - willLeave={this.willLeave} - > - {interpolatedStyles => - <div className='modal-root' ref={this.setRef}> - {interpolatedStyles.map(({ key, data: { type, props }, style }) => ( - <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> - <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> - <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> - <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> - {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} - </BundleContainer> - </div> - </div> - ))} + <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}> + <div style={{ pointerEvents: visible ? 'auto' : 'none' }}> + <div role='presentation' className='modal-root__overlay' onClick={onClose} /> + <div role='dialog' className='modal-root__container'> + { + visible ? + (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> + {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} + </BundleContainer>) : + null + } </div> - } - </TransitionMotion> + </div> + </div> ); } diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js index af9e6bf45..7694e5ab3 100644 --- a/app/javascript/mastodon/features/ui/components/tabs_bar.js +++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import NavLink from 'react-router-dom/NavLink'; +import { NavLink } from 'react-router-dom'; import { FormattedMessage, injectIntl } from 'react-intl'; import { debounce } from 'lodash'; import { isUserTouching } from '../../../is_mobile'; diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index 867c73ed5..1437deeb0 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -23,6 +23,7 @@ export default class VideoModal extends ImmutablePureComponent { src={media.get('url')} startTime={time} onCloseVideo={onClose} + description={media.get('description')} /> </div> </div> diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 73bd23432..14a5f6224 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { Redirect, withRouter } from 'react-router-dom'; import { isMobile } from '../../is_mobile'; import { debounce } from 'lodash'; -import { uploadCompose } from '../../actions/compose'; +import { uploadCompose, resetCompose } from '../../actions/compose'; import { refreshHomeTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; import { clearHeight } from '../../actions/height_cache'; @@ -38,6 +38,7 @@ import { Mutes, PinnedStatuses, } from './util/async-components'; +import { HotKeys } from 'react-hotkeys'; // Dummy import, to make sure that <Status /> ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. @@ -48,12 +49,39 @@ const mapStateToProps = state => ({ layout: state.getIn(['local_settings', 'layout']), isWide: state.getIn(['local_settings', 'stretch']), navbarUnder: state.getIn(['local_settings', 'navbar_under']), + me: state.getIn(['meta', 'me']), isComposing: state.getIn(['compose', 'is_composing']), }); +const keyMap = { + new: 'n', + search: 's', + forceNew: 'option+n', + focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], + reply: 'r', + favourite: 'f', + boost: 'b', + mention: 'm', + open: ['enter', 'o'], + openProfile: 'p', + moveDown: ['down', 'j'], + moveUp: ['up', 'k'], + back: 'backspace', + goToHome: 'g h', + goToNotifications: 'g n', + goToLocal: 'g l', + goToFederated: 'g t', + goToStart: 'g s', + goToFavourites: 'g f', + goToPinned: 'g p', + goToProfile: 'g u', + goToBlocked: 'g b', + goToMuted: 'g m', +}; + @connect(mapStateToProps) @withRouter -export default class UI extends React.PureComponent { +export default class UI extends React.Component { static contextTypes = { router: PropTypes.object.isRequired, @@ -67,6 +95,7 @@ export default class UI extends React.PureComponent { systemFontUi: PropTypes.bool, navbarUnder: PropTypes.bool, isComposing: PropTypes.bool, + me: PropTypes.string, location: PropTypes.object, }; @@ -164,6 +193,12 @@ export default class UI extends React.PureComponent { this.props.dispatch(refreshNotifications()); } + componentDidMount () { + this.hotkeys.__mousetrap__.stopCallback = (e, element) => { + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + }; + } + shouldComponentUpdate (nextProps) { if (nextProps.isComposing !== this.props.isComposing) { // Avoid expensive update just to toggle a class @@ -201,8 +236,94 @@ export default class UI extends React.PureComponent { this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); } - setOverlayRef = c => { - this.overlay = c; + handleHotkeyNew = e => { + e.preventDefault(); + + const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea'); + + if (element) { + element.focus(); + } + } + + handleHotkeySearch = e => { + e.preventDefault(); + + const element = this.node.querySelector('.search__input'); + + if (element) { + element.focus(); + } + } + + handleHotkeyForceNew = e => { + this.handleHotkeyNew(e); + this.props.dispatch(resetCompose()); + } + + handleHotkeyFocusColumn = e => { + const index = (e.key * 1) + 1; // First child is drawer, skip that + const column = this.node.querySelector(`.column:nth-child(${index})`); + + if (column) { + const status = column.querySelector('.focusable'); + + if (status) { + status.focus(); + } + } + } + + handleHotkeyBack = () => { + if (window.history && window.history.length === 1) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } + } + + setHotkeysRef = c => { + this.hotkeys = c; + } + + handleHotkeyGoToHome = () => { + this.context.router.history.push('/timelines/home'); + } + + handleHotkeyGoToNotifications = () => { + this.context.router.history.push('/notifications'); + } + + handleHotkeyGoToLocal = () => { + this.context.router.history.push('/timelines/public/local'); + } + + handleHotkeyGoToFederated = () => { + this.context.router.history.push('/timelines/public'); + } + + handleHotkeyGoToStart = () => { + this.context.router.history.push('/getting-started'); + } + + handleHotkeyGoToFavourites = () => { + this.context.router.history.push('/favourites'); + } + + handleHotkeyGoToPinned = () => { + this.context.router.history.push('/pinned'); + } + + handleHotkeyGoToProfile = () => { + this.context.router.history.push(`/accounts/${this.props.me}`); + } + + handleHotkeyGoToBlocked = () => { + this.context.router.history.push('/blocks'); + } + + handleHotkeyGoToMuted = () => { + this.context.router.history.push('/mutes'); } render () { @@ -226,45 +347,67 @@ export default class UI extends React.PureComponent { 'navbar-under': navbarUnder, }); + const handlers = { + new: this.handleHotkeyNew, + search: this.handleHotkeySearch, + forceNew: this.handleHotkeyForceNew, + focusColumn: this.handleHotkeyFocusColumn, + back: this.handleHotkeyBack, + goToHome: this.handleHotkeyGoToHome, + goToNotifications: this.handleHotkeyGoToNotifications, + goToLocal: this.handleHotkeyGoToLocal, + goToFederated: this.handleHotkeyGoToFederated, + goToStart: this.handleHotkeyGoToStart, + goToFavourites: this.handleHotkeyGoToFavourites, + goToPinned: this.handleHotkeyGoToPinned, + goToProfile: this.handleHotkeyGoToProfile, + goToBlocked: this.handleHotkeyGoToBlocked, + goToMuted: this.handleHotkeyGoToMuted, + }; + return ( - <div className={className} ref={this.setRef}> - {navbarUnder ? null : (<TabsBar />)} - <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}> - <WrappedSwitch> - <Redirect from='/' to='/getting-started' exact /> - <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> - <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> - <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> - <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> - <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> - - <WrappedRoute path='/notifications' component={Notifications} content={children} /> - <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> - <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> - - <WrappedRoute path='/statuses/new' component={Compose} content={children} /> - <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> - <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> - <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> - - <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> - <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> - <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> - <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> - - <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> - <WrappedRoute path='/blocks' component={Blocks} content={children} /> - <WrappedRoute path='/mutes' component={Mutes} content={children} /> - - <WrappedRoute component={GenericNotFound} content={children} /> - </WrappedSwitch> - </ColumnsAreaContainer> - <NotificationsContainer /> - {navbarUnder ? (<TabsBar />) : null} - <LoadingBarContainer className='loading-bar' /> - <ModalContainer /> - <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> - </div> + <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}> + <div className={className} ref={this.setRef}> + {navbarUnder ? null : (<TabsBar />)} + + <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}> + <WrappedSwitch> + <Redirect from='/' to='/getting-started' exact /> + <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> + <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> + <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> + <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> + <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> + + <WrappedRoute path='/notifications' component={Notifications} content={children} /> + <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> + <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> + + <WrappedRoute path='/statuses/new' component={Compose} content={children} /> + <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> + + <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> + <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> + <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> + <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> + + <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> + <WrappedRoute path='/blocks' component={Blocks} content={children} /> + <WrappedRoute path='/mutes' component={Mutes} content={children} /> + + <WrappedRoute component={GenericNotFound} content={children} /> + </WrappedSwitch> + </ColumnsAreaContainer> + + <NotificationsContainer /> + {navbarUnder ? (<TabsBar />) : null} + <LoadingBarContainer className='loading-bar' /> + <ModalContainer /> + <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> + </div> + </HotKeys> ); } diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 5d640810f..7f2b303a7 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -1,3 +1,7 @@ +export function EmojiPicker () { + return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker'); +} + export function Compose () { return import(/* webpackChunkName: "features/compose" */'../../compose'); } @@ -101,10 +105,6 @@ export function MediaGallery () { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } -export function VideoPlayer () { - return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); -} - export function Video () { return import(/* webpackChunkName: "features/video" */'../../video'); } diff --git a/app/javascript/mastodon/features/ui/util/fullscreen.js b/app/javascript/mastodon/features/ui/util/fullscreen.js new file mode 100644 index 000000000..cf5d0cf98 --- /dev/null +++ b/app/javascript/mastodon/features/ui/util/fullscreen.js @@ -0,0 +1,46 @@ +// APIs for normalizing fullscreen operations. Note that Edge uses +// the WebKit-prefixed APIs currently (as of Edge 16). + +export const isFullscreen = () => document.fullscreenElement || + document.webkitFullscreenElement || + document.mozFullScreenElement; + +export const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } +}; + +export const requestFullscreen = el => { + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.webkitRequestFullscreen) { + el.webkitRequestFullscreen(); + } else if (el.mozRequestFullScreen) { + el.mozRequestFullScreen(); + } +}; + +export const attachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.addEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.addEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.addEventListener('mozfullscreenchange', listener); + } +}; + +export const detachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.removeEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.removeEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.removeEventListener('mozfullscreenchange', listener); + } +}; diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.js b/app/javascript/mastodon/features/ui/util/react_router_helpers.js index ede578e56..86b30d488 100644 --- a/app/javascript/mastodon/features/ui/util/react_router_helpers.js +++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.js @@ -1,7 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Switch from 'react-router-dom/Switch'; -import Route from 'react-router-dom/Route'; +import { Switch, Route } from 'react-router-dom'; import ColumnLoading from '../components/column_loading'; import BundleColumnError from '../components/bundle_column_error'; diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index f228e434b..003bf23a8 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { throttle } from 'lodash'; import classNames from 'classnames'; +import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, @@ -69,41 +70,13 @@ const getPointerPosition = (el, event) => { return position; }; -const isFullscreen = () => document.fullscreenElement || - document.webkitFullscreenElement || - document.mozFullScreenElement || - document.msFullscreenElement; - -const exitFullscreen = () => { - if (document.exitFullscreen) { - document.exitFullscreen(); - } else if (document.webkitExitFullscreen) { - document.webkitExitFullscreen(); - } else if (document.mozCancelFullScreen) { - document.mozCancelFullScreen(); - } else if (document.msExitFullscreen) { - document.msExitFullscreen(); - } -}; - -const requestFullscreen = el => { - if (el.requestFullscreen) { - el.requestFullscreen(); - } else if (el.webkitRequestFullscreen) { - el.webkitRequestFullscreen(); - } else if (el.mozRequestFullScreen) { - el.mozRequestFullScreen(); - } else if (el.msRequestFullscreen) { - el.msRequestFullscreen(); - } -}; - @injectIntl export default class Video extends React.PureComponent { static propTypes = { preview: PropTypes.string, src: PropTypes.string.isRequired, + alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, @@ -236,6 +209,12 @@ export default class Video extends React.PureComponent { } } + handleProgress = () => { + if (this.video.buffered.length > 0) { + this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 }); + } + } + handleOpenVideo = () => { this.video.pause(); this.props.onOpenVideo(this.video.currentTime); @@ -247,8 +226,8 @@ export default class Video extends React.PureComponent { } render () { - const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props; - const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; + const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props; + const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; return ( <div className={classNames('video-player', { inactive: !revealed, inline: width && height && !fullscreen, fullscreen })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> @@ -256,10 +235,11 @@ export default class Video extends React.PureComponent { ref={this.setVideoRef} src={src} poster={preview} - preload={!!startTime} + preload={startTime ? 'auto' : 'none'} loop role='button' tabIndex='0' + aria-label={alt} width={width} height={height} onClick={this.togglePlay} @@ -267,6 +247,7 @@ export default class Video extends React.PureComponent { onPause={this.handlePause} onTimeUpdate={this.handleTimeUpdate} onLoadedData={this.handleLoadedData} + onProgress={this.handleProgress} /> <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> @@ -276,6 +257,7 @@ export default class Video extends React.PureComponent { <div className={classNames('video-player__controls', { active: paused || hovered })}> <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> + <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} /> <div className='video-player__seek__progress' style={{ width: `${progress}%` }} /> <span diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index bd09f1970..799819c7c 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -1,31 +1,31 @@ { "account.block": "حظر @{name}", - "account.block_domain": "Hide everything from {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}", + "account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.", "account.edit_profile": "تعديل الملف الشخصي", "account.follow": "تابِع", "account.followers": "المتابعون", "account.follows": "يتبع", "account.follows_you": "يتابعك", - "account.media": "Media", + "account.media": "وسائط", "account.mention": "أُذكُر @{name}", "account.mute": "أكتم @{name}", "account.posts": "المشاركات", "account.report": "أبلغ عن @{name}", "account.requested": "في انتظار الموافقة", - "account.share": "Share @{name}'s profile", + "account.share": "مشاركة @{name}'s profile", "account.unblock": "إلغاء الحظر عن @{name}", - "account.unblock_domain": "Unhide {domain}", + "account.unblock_domain": "فك حظر {domain}", "account.unfollow": "إلغاء المتابعة", "account.unmute": "إلغاء الكتم عن @{name}", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "عرض الملف الشخصي كاملا", "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", + "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.", + "bundle_column_error.retry": "إعادة المحاولة", + "bundle_column_error.title": "خطأ في الشبكة", + "bundle_modal_error.close": "أغلق", + "bundle_modal_error.message": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.", + "bundle_modal_error.retry": "إعادة المحاولة", "column.blocks": "الحسابات المحجوبة", "column.community": "الخيط العام المحلي", "column.favourites": "المفضلة", @@ -33,15 +33,15 @@ "column.home": "الرئيسية", "column.mutes": "الحسابات المكتومة", "column.notifications": "الإشعارات", - "column.pins": "Pinned toot", + "column.pins": "التبويقات المثبتة", "column.public": "الخيط العام الموحد", "column_back_button.label": "العودة", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", - "column_header.pin": "Pin", - "column_header.show_settings": "Show settings", - "column_header.unpin": "Unpin", + "column_header.hide_settings": "إخفاء الإعدادات", + "column_header.moveLeft_settings": "نقل القائمة إلى اليسار", + "column_header.moveRight_settings": "نقل القائمة إلى اليمين", + "column_header.pin": "تدبيس", + "column_header.show_settings": "عرض الإعدادات", + "column_header.unpin": "فك التدبيس", "column_subheading.navigation": "التصفح", "column_subheading.settings": "الإعدادات", "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.", @@ -57,16 +57,16 @@ "confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟", "confirmations.delete.confirm": "حذف", "confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟", - "confirmations.domain_block.confirm": "Hide entire domain", + "confirmations.domain_block.confirm": "إخفاء إسم النطاق كاملا", "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", "confirmations.mute.confirm": "أكتم", "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", - "embed.instructions": "Embed this status on your website by copying the code below.", - "embed.preview": "Here is what it will look like:", + "confirmations.unfollow.confirm": "إلغاء المتابعة", + "confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟", + "embed.instructions": "يمكنكم إدماج هذه الحالة على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.", + "embed.preview": "هكذا ما سوف يبدو عليه :", "emoji_button.activity": "الأنشطة", - "emoji_button.custom": "Custom", + "emoji_button.custom": "مخصص", "emoji_button.flags": "الأعلام", "emoji_button.food": "الطعام والشراب", "emoji_button.label": "أدرج إيموجي", @@ -74,9 +74,9 @@ "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "أشياء", "emoji_button.people": "الناس", - "emoji_button.recent": "Frequently used", + "emoji_button.recent": "الشائعة الإستخدام", "emoji_button.search": "ابحث...", - "emoji_button.search_results": "Search results", + "emoji_button.search_results": "نتائج البحث", "emoji_button.symbols": "رموز", "emoji_button.travel": "أماكن و أسفار", "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.", @@ -100,8 +100,8 @@ "home.column_settings.show_replies": "عرض الردود", "home.settings": "إعدادات العمود", "lightbox.close": "إغلاق", - "lightbox.next": "Next", - "lightbox.previous": "Previous", + "lightbox.next": "التالي", + "lightbox.previous": "العودة", "loading_indicator.label": "تحميل ...", "media_gallery.toggle_visible": "عرض / إخفاء", "missing_indicator.label": "تعذر العثور عليه", @@ -113,7 +113,7 @@ "navigation_bar.info": "معلومات إضافية", "navigation_bar.logout": "خروج", "navigation_bar.mutes": "الحسابات المكتومة", - "navigation_bar.pins": "Pinned toots", + "navigation_bar.pins": "التبويقات المثبتة", "navigation_bar.preferences": "التفضيلات", "navigation_bar.public_timeline": "الخيط العام الموحد", "notification.favourite": "{name} أعجب بمنشورك", @@ -126,8 +126,8 @@ "notifications.column_settings.favourite": "المُفَضَّلة :", "notifications.column_settings.follow": "متابعُون جُدُد :", "notifications.column_settings.mention": "الإشارات :", - "notifications.column_settings.push": "Push notifications", - "notifications.column_settings.push_meta": "This device", + "notifications.column_settings.push": "الإخطارات المدفوعة", + "notifications.column_settings.push_meta": "هذا الجهاز", "notifications.column_settings.reblog": "الترقيّات:", "notifications.column_settings.show": "إعرِضها في عمود", "notifications.column_settings.sound": "أصدر صوتا", @@ -165,18 +165,23 @@ "report.submit": "إرسال", "report.target": "إبلاغ", "search.placeholder": "ابحث", + "search_popout.search_format": "نمط البحث المتقدم", + "search_popout.tips.hashtag": "وسم", + "search_popout.tips.status": "حالة", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "مستخدِم", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", - "standalone.public_title": "A look inside...", + "standalone.public_title": "نظرة على ...", "status.cannot_reblog": "تعذرت ترقية هذا المنشور", "status.delete": "إحذف", - "status.embed": "Embed", + "status.embed": "إدماج", "status.favourite": "أضف إلى المفضلة", "status.load_more": "حمّل المزيد", "status.media_hidden": "الصورة مستترة", "status.mention": "أذكُر @{name}", - "status.mute_conversation": "Mute conversation", + "status.mute_conversation": "كتم المحادثة", "status.open": "وسع هذه المشاركة", - "status.pin": "Pin on profile", + "status.pin": "تدبيس على الملف الشخصي", "status.reblog": "رَقِّي", "status.reblogged_by": "{name} رقى", "status.reply": "ردّ", @@ -184,11 +189,11 @@ "status.report": "إبلِغ عن @{name}", "status.sensitive_toggle": "اضغط للعرض", "status.sensitive_warning": "محتوى حساس", - "status.share": "Share", + "status.share": "مشاركة", "status.show_less": "إعرض أقلّ", "status.show_more": "أظهر المزيد", - "status.unmute_conversation": "Unmute conversation", - "status.unpin": "Unpin from profile", + "status.unmute_conversation": "فك الكتم عن المحادثة", + "status.unpin": "فك التدبيس من الملف الشخصي", "tabs_bar.compose": "تحرير", "tabs_bar.federated_timeline": "الموحَّد", "tabs_bar.home": "الرئيسية", @@ -196,19 +201,16 @@ "tabs_bar.notifications": "الإخطارات", "upload_area.title": "إسحب ثم أفلت للرفع", "upload_button.label": "إضافة وسائط", + "upload_form.description": "وصف للمعاقين بصريا", "upload_form.undo": "إلغاء", "upload_progress.label": "يرفع...", - "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", - "video_player.expand": "وسّع الفيديو", - "video_player.toggle_sound": "تبديل الصوت", - "video_player.toggle_visible": "إظهار / إخفاء الفيديو", - "video_player.video_error": "تعذر تشغيل الفيديو" + "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/bg.json b/app/javascript/mastodon/locales/bg.json index d391a57ba..240e3725e 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Търсене", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Известия", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Добави медия", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Отмяна", "upload_progress.label": "Uploading...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Expand video", - "video_player.toggle_sound": "Звук", - "video_player.toggle_visible": "Toggle visibility", - "video_player.video_error": "Video could not be played" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 286da3ac6..b5051a32d 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -165,6 +165,11 @@ "report.submit": "Enviar", "report.target": "Informes", "search.placeholder": "Cercar", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Aquesta publicació no pot ser retootejada", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notificacions", "upload_area.title": "Arrossega i deixa anar per carregar", "upload_button.label": "Afegir multimèdia", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Desfer", "upload_progress.label": "Pujant...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Ampliar el vídeo", - "video_player.toggle_sound": "Alternar so", - "video_player.toggle_visible": "Alternar visibilitat", - "video_player.video_error": "El vídeo no es pot reproduir" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 461e7e304..b79b1b2f0 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -1,7 +1,7 @@ { "account.block": "@{name} blocken", "account.block_domain": "Alles von {domain} verstecken", - "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.", + "account.disclaimer_full": "Das Profil wird möglicherweise unvollständig wiedergegeben.", "account.edit_profile": "Profil bearbeiten", "account.follow": "Folgen", "account.followers": "Folgende", @@ -18,11 +18,11 @@ "account.unblock_domain": "{domain} wieder anzeigen", "account.unfollow": "Entfolgen", "account.unmute": "@{name} nicht mehr stummschalten", - "account.view_full_profile": "Komplettes Profil anzeigen", + "account.view_full_profile": "Vollständiges Profil anzeigen", "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.", "bundle_column_error.retry": "Erneut versuchen", - "bundle_column_error.title": "Netzwerkfehlher", + "bundle_column_error.title": "Netzwerkfehler", "bundle_modal_error.close": "Schließen", "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.", "bundle_modal_error.retry": "Erneut versuchen", @@ -33,18 +33,18 @@ "column.home": "Startseite", "column.mutes": "Stummgeschaltete Profile", "column.notifications": "Mitteilungen", - "column.pins": "Pinned toot", + "column.pins": "Angeheftete Beiträge", "column.public": "Gesamtes bekanntes Netz", "column_back_button.label": "Zurück", "column_header.hide_settings": "Einstellungen verbergen", - "column_header.moveLeft_settings": "Spalte links verschieben", - "column_header.moveRight_settings": "Spalte rechts verschieben", + "column_header.moveLeft_settings": "Spalte nach links verschieben", + "column_header.moveRight_settings": "Spalte nach rechts verschieben", "column_header.pin": "Anheften", "column_header.show_settings": "Einstellungen anzeigen", "column_header.unpin": "Lösen", "column_subheading.navigation": "Navigation", "column_subheading.settings": "Einstellungen", - "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.", + "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.", "compose_form.lock_disclaimer.lock": "gesperrt", "compose_form.placeholder": "Worüber möchtest du schreiben?", "compose_form.publish": "Tröt", @@ -56,106 +56,106 @@ "confirmations.block.confirm": "Blockieren", "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?", "confirmations.delete.confirm": "Löschen", - "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?", + "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?", "confirmations.domain_block.confirm": "Die ganze Domain verbergen", - "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.", + "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen reichen ein paar gezielte Blocks aus.", "confirmations.mute.confirm": "Stummschalten", - "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?", + "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?", "confirmations.unfollow.confirm": "Entfolgen", - "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?", - "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.", + "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchtest?", + "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, indem du den folgenden Code einfügst.", "embed.preview": "So wird es aussehen:", "emoji_button.activity": "Aktivitäten", - "emoji_button.custom": "Custom", + "emoji_button.custom": "Eigene", "emoji_button.flags": "Flaggen", "emoji_button.food": "Essen und Trinken", "emoji_button.label": "Emoji einfügen", "emoji_button.nature": "Natur", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", - "emoji_button.objects": "Dinge", - "emoji_button.people": "Leute", - "emoji_button.recent": "Frequently used", - "emoji_button.search": "Suche…", - "emoji_button.search_results": "Search results", + "emoji_button.not_found": "Keine Emojis!! (╯°□°)╯︵ ┻━┻", + "emoji_button.objects": "Gegenstände", + "emoji_button.people": "Personen", + "emoji_button.recent": "Häufig benutzt", + "emoji_button.search": "Suchen", + "emoji_button.search_results": "Suchergebnisse", "emoji_button.symbols": "Symbole", - "emoji_button.travel": "Reise und Orte", - "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!", - "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.", - "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.", - "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.", + "emoji_button.travel": "Reisen und Orte", + "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe einen öffentlichen Beitrag, um den Ball ins Rollen zu bringen!", + "empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.", + "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder nutze die Suche, um loszulegen und andere Leute zu finden.", + "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv warst, wird sie für dich so schnell wie möglich neu erstellt.", "empty_column.home.public_timeline": "die öffentliche Zeitleiste", - "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.", - "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.", + "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um ins Gespräch zu kommen.", + "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um die Zeitleiste aufzufüllen", "follow_request.authorize": "Erlauben", "follow_request.reject": "Ablehnen", - "getting_started.appsshort": "Anwendungen", + "getting_started.appsshort": "Apps", "getting_started.faq": "Häufig gestellte Fragen", "getting_started.heading": "Erste Schritte", - "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", + "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen oder Probleme melden.", "getting_started.userguide": "Bedienungsanleitung", - "home.column_settings.advanced": "Fortgeschritten", + "home.column_settings.advanced": "Erweitert", "home.column_settings.basic": "Einfach", - "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke", + "home.column_settings.filter_regex": "Mit regulären Ausdrücken filtern", "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", "home.column_settings.show_replies": "Antworten anzeigen", "home.settings": "Spalteneinstellungen", "lightbox.close": "Schließen", "lightbox.next": "Weiter", "lightbox.previous": "Zurück", - "loading_indicator.label": "Lade…", - "media_gallery.toggle_visible": "Sichtbarkeit einstellen", + "loading_indicator.label": "Wird geladen …", + "media_gallery.toggle_visible": "Sichtbarkeit umschalten", "missing_indicator.label": "Nicht gefunden", "navigation_bar.blocks": "Blockierte Profile", "navigation_bar.community_timeline": "Lokale Zeitleiste", "navigation_bar.edit_profile": "Profil bearbeiten", "navigation_bar.favourites": "Favoriten", "navigation_bar.follow_requests": "Folgeanfragen", - "navigation_bar.info": "Erweiterte Informationen", + "navigation_bar.info": "Über diese Instanz", "navigation_bar.logout": "Abmelden", "navigation_bar.mutes": "Stummgeschaltete Profile", - "navigation_bar.pins": "Pinned toots", + "navigation_bar.pins": "Angeheftete Beiträge", "navigation_bar.preferences": "Einstellungen", "navigation_bar.public_timeline": "Föderierte Zeitleiste", - "notification.favourite": "{name} favorisierte deinen Status", + "notification.favourite": "{name} hat deinen Beitrag favorisiert", "notification.follow": "{name} folgt dir", - "notification.mention": "{name} erwähnte dich", - "notification.reblog": "{name} teilte deinen Status", + "notification.mention": "{name} hat dich erwähnt", + "notification.reblog": "{name} hat deinen Beitrag geteilt", "notifications.clear": "Mitteilungen löschen", - "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?", + "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchtest?", "notifications.column_settings.alert": "Desktop-Benachrichtigungen", "notifications.column_settings.favourite": "Favorisierungen:", "notifications.column_settings.follow": "Neue Folgende:", "notifications.column_settings.mention": "Erwähnungen:", - "notifications.column_settings.push": "Push notifications", - "notifications.column_settings.push_meta": "This device", + "notifications.column_settings.push": "Push-Benachrichtigungen", + "notifications.column_settings.push_meta": "Auf diesem Gerät", "notifications.column_settings.reblog": "Geteilte Beiträge:", "notifications.column_settings.show": "In der Spalte anzeigen", "notifications.column_settings.sound": "Ton abspielen", "onboarding.done": "Fertig", "onboarding.next": "Weiter", - "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.", + "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen sind sie die öffentlichen Zeitleisten. In ihnen kannst du viel Neues entdecken!", "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.", - "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.", + "onboarding.page_four.notifications": "Wenn jemand mit dir interagiert, bekommst du eine Mitteilung.", "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.", "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}", "onboarding.page_one.welcome": "Willkommen bei Mastodon!", "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.", - "onboarding.page_six.almost_done": "Fast fertig…", + "onboarding.page_six.almost_done": "Fast fertig …", "onboarding.page_six.appetoot": "Guten Appetröt!", - "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.", - "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", + "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und weitere Plattformen.", + "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen, Probleme melden und Wünsche äußern.", "onboarding.page_six.guidelines": "Richtlinien", - "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!", - "onboarding.page_six.various_app": "mobile Anwendungen", - "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.", - "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", - "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.", + "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut.", + "onboarding.page_six.various_app": "Apps", + "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen und deine Beschreibung anzupassen. Dort findest du auch weitere Einstellungen.", + "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute zu finden und mit Hashtags wie {illustration} oder {introductions} nach Beiträgen zu suchen. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", + "onboarding.page_two.compose": "Schreibe deine Beiträge in der Schreiben-Spalte. Mit den Symbolen unter dem Eingabefeld kannst du Bilder hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.", "onboarding.skip": "Überspringen", - "privacy.change": "Privatsphäre des Status anpassen", + "privacy.change": "Sichtbarkeit des Beitrags anpassen", "privacy.direct.long": "Beitrag nur an erwähnte Profile", "privacy.direct.short": "Direkt", "privacy.private.long": "Beitrag nur an Folgende", - "privacy.private.short": "Privat", + "privacy.private.short": "Nur Folgende", "privacy.public.long": "Beitrag an öffentliche Zeitleisten", "privacy.public.short": "Öffentlich", "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen", @@ -163,26 +163,31 @@ "reply_indicator.cancel": "Abbrechen", "report.placeholder": "Zusätzliche Kommentare", "report.submit": "Absenden", - "report.target": "Melden", + "report.target": "{target} melden", "search.placeholder": "Suche", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", - "standalone.public_title": "Vorschau…", + "standalone.public_title": "Ein kleiner Einblick …", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", "status.delete": "Löschen", "status.embed": "Einbetten", "status.favourite": "Favorisieren", "status.load_more": "Weitere laden", "status.media_hidden": "Medien versteckt", - "status.mention": "Erwähnen", + "status.mention": "@{name} erwähnen", "status.mute_conversation": "Thread stummschalten", - "status.open": "Öffnen", - "status.pin": "Auf dem Profil anheften", + "status.open": "Diesen Beitrag öffnen", + "status.pin": "Im Profil anheften", "status.reblog": "Teilen", "status.reblogged_by": "{name} teilte", "status.reply": "Antworten", "status.replyAll": "Auf Thread antworten", "status.report": "@{name} melden", - "status.sensitive_toggle": "Klicke, um sie zu sehen", + "status.sensitive_toggle": "Zum Ansehen klicken", "status.sensitive_warning": "Heikle Inhalte", "status.share": "Teilen", "status.show_less": "Weniger anzeigen", @@ -194,21 +199,18 @@ "tabs_bar.home": "Startseite", "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Mitteilungen", - "upload_area.title": "Hereinziehen zum Hochladen", + "upload_area.title": "Zum Hochladen hereinziehen", "upload_button.label": "Mediendatei hinzufügen", + "upload_form.description": "Für Menschen mit Sehbehinderung beschreiben", "upload_form.undo": "Entfernen", - "upload_progress.label": "Lade hoch…", - "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", + "upload_progress.label": "Wird hochgeladen …", + "video.close": "Video schließen", + "video.exit_fullscreen": "Vollbild verlassen", + "video.expand": "Video vergrößern", + "video.fullscreen": "Vollbild", + "video.hide": "Video verbergen", + "video.mute": "Stummschalten", "video.pause": "Pause", - "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Videoanzeige vergrößern", - "video_player.toggle_sound": "Ton umschalten", - "video_player.toggle_visible": "Sichtbarkeit umschalten", - "video_player.video_error": "Video konnte nicht abgespielt werden" + "video.play": "Abspielen", + "video.unmute": "Ton einschalten" } diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 5b711fd26..8fdb8c44c 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -135,6 +135,31 @@ { "descriptors": [ { + "defaultMessage": "now", + "id": "relative_time.just_now" + }, + { + "defaultMessage": "{number}s", + "id": "relative_time.seconds" + }, + { + "defaultMessage": "{number}m", + "id": "relative_time.minutes" + }, + { + "defaultMessage": "{number}h", + "id": "relative_time.hours" + }, + { + "defaultMessage": "{number}d", + "id": "relative_time.days" + } + ], + "path": "app/javascript/mastodon/components/relative_timestamp.json" + }, + { + "descriptors": [ + { "defaultMessage": "Delete", "id": "status.delete" }, @@ -230,39 +255,6 @@ { "descriptors": [ { - "defaultMessage": "Toggle sound", - "id": "video_player.toggle_sound" - }, - { - "defaultMessage": "Toggle visibility", - "id": "video_player.toggle_visible" - }, - { - "defaultMessage": "Expand video", - "id": "video_player.expand" - }, - { - "defaultMessage": "Sensitive content", - "id": "status.sensitive_warning" - }, - { - "defaultMessage": "Click to view", - "id": "status.sensitive_toggle" - }, - { - "defaultMessage": "Media hidden", - "id": "status.media_hidden" - }, - { - "defaultMessage": "Video could not be played", - "id": "video_player.video_error" - } - ], - "path": "app/javascript/mastodon/components/video_player.json" - }, - { - "descriptors": [ - { "defaultMessage": "Unfollow", "id": "confirmations.unfollow.confirm" }, @@ -640,6 +632,26 @@ { "defaultMessage": "Search", "id": "search.placeholder" + }, + { + "defaultMessage": "Advanced search format", + "id": "search_popout.search_format" + }, + { + "defaultMessage": "hashtag", + "id": "search_popout.tips.hashtag" + }, + { + "defaultMessage": "user", + "id": "search_popout.tips.user" + }, + { + "defaultMessage": "status", + "id": "search_popout.tips.status" + }, + { + "defaultMessage": "Simple text returns matching display names, usernames and hashtags", + "id": "search_popout.tips.text" } ], "path": "app/javascript/mastodon/features/compose/components/search.json" @@ -656,20 +668,24 @@ { "descriptors": [ { - "defaultMessage": "Undo", - "id": "upload_form.undo" + "defaultMessage": "Uploading...", + "id": "upload_progress.label" } ], - "path": "app/javascript/mastodon/features/compose/components/upload_form.json" + "path": "app/javascript/mastodon/features/compose/components/upload_progress.json" }, { "descriptors": [ { - "defaultMessage": "Uploading...", - "id": "upload_progress.label" + "defaultMessage": "Undo", + "id": "upload_form.undo" + }, + { + "defaultMessage": "Describe for the visually impaired", + "id": "upload_form.description" } ], - "path": "app/javascript/mastodon/features/compose/components/upload_progress.json" + "path": "app/javascript/mastodon/features/compose/components/upload.json" }, { "descriptors": [ diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index fc6aa4280..b0dbc46bd 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting {target}", "search.placeholder": "Search", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notifications", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Add media", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Undo", "upload_progress.label": "Uploading...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Expand video", - "video_player.toggle_sound": "Toggle sound", - "video_player.toggle_visible": "Toggle visibility", - "video_player.video_error": "Video could not be played" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 21b92ed3a..1ccd2b817 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Serĉi", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Sciigoj", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Aldoni enhavaĵon", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Malfari", "upload_progress.label": "Uploading...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Expand video", - "video_player.toggle_sound": "Aktivigi sonojn", - "video_player.toggle_visible": "Toggle visibility", - "video_player.video_error": "Video could not be played" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 59c7dc5a7..f6bfbb04d 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -165,6 +165,11 @@ "report.submit": "Publicar", "report.target": "Reportando", "search.placeholder": "Buscar", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "standalone.public_title": "Un pequeño vistazo...", "status.cannot_reblog": "Este toot no puede retootearse", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notificaciones", "upload_area.title": "Arrastra y suelta para subir", "upload_button.label": "Subir multimedia", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Deshacer", "upload_progress.label": "Subiendo…", "video.close": "Cerrar video", @@ -206,9 +212,5 @@ "video.mute": "Silenciar sonido", "video.pause": "Pausar", "video.play": "Reproducir", - "video.unmute": "Dejar de silenciar sonido", - "video_player.expand": "Expandir vídeo", - "video_player.toggle_sound": "Activar/Desactivar sonido", - "video_player.toggle_visible": "Cambiar visibilidad", - "video_player.video_error": "No se pudo reproducir el vídeo" + "video.unmute": "Dejar de silenciar sonido" } diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 6e4771392..13fb91278 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -165,6 +165,11 @@ "report.submit": "بفرست", "report.target": "گزارشدادن", "search.placeholder": "جستجو", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}", "standalone.public_title": "نگاهی به کاربران این سرور...", "status.cannot_reblog": "این نوشته را نمیشود بازبوقید", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "اعلانها", "upload_area.title": "برای بارگذاری به اینجا بکشید", "upload_button.label": "افزودن تصویر", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "واگردانی", "upload_progress.label": "بارگذاری...", "video.close": "بستن ویدیو", @@ -206,9 +212,5 @@ "video.mute": "قطع صدا", "video.pause": "توقف", "video.play": "پخش", - "video.unmute": "پخش صدا", - "video_player.expand": "بازکردن ویدیو", - "video_player.toggle_sound": "تغییر صداداری", - "video_player.toggle_visible": "تغییر پیدایی", - "video_player.video_error": "ویدیو نمیتواند پخش شود" + "video.unmute": "پخش صدا" } diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index ccdf19dd6..425b3d82a 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Hae", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Ilmoitukset", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Lisää mediaa", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Peru", "upload_progress.label": "Uploading...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Expand video", - "video_player.toggle_sound": "Äänet päälle/pois", - "video_player.toggle_visible": "Toggle visibility", - "video_player.video_error": "Video could not be played" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 417c1062a..350d92c44 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -66,7 +66,7 @@ "embed.instructions": "Intégrez ce statut à votre site en copiant ce code ci-dessous.", "embed.preview": "Il apparaîtra comme cela : ", "emoji_button.activity": "Activités", - "emoji_button.custom": "Custom", + "emoji_button.custom": "Personnalisés", "emoji_button.flags": "Drapeaux", "emoji_button.food": "Boire et manger", "emoji_button.label": "Insérer un emoji", @@ -74,9 +74,9 @@ "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "Objets", "emoji_button.people": "Personnages", - "emoji_button.recent": "Frequently used", + "emoji_button.recent": "Fréquemment utilisés", "emoji_button.search": "Recherche…", - "emoji_button.search_results": "Search results", + "emoji_button.search_results": "Résultats de la recherche", "emoji_button.symbols": "Symboles", "emoji_button.travel": "Lieux et voyages", "empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !", @@ -165,6 +165,11 @@ "report.submit": "Envoyer", "report.target": "Signalement", "search.placeholder": "Rechercher", + "search_popout.search_format": "Recherche avancée", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "statuts", + "search_popout.tips.text": "Un texte simple renvoie les noms affichés, les noms d'utilisateur et les hashtags correspondants", + "search_popout.tips.user": "utilisateur", "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", "standalone.public_title": "Jeter un coup d’œil…", "status.cannot_reblog": "Cette publication ne peut être boostée", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notifications", "upload_area.title": "Glissez et déposez pour envoyer", "upload_button.label": "Joindre un média", + "upload_form.description": "Décrire pour les malvoyants", "upload_form.undo": "Annuler", "upload_progress.label": "Envoi en cours…", "video.close": "Fermer la vidéo", @@ -206,9 +212,5 @@ "video.mute": "Couper le son", "video.pause": "Pause", "video.play": "Lecture", - "video.unmute": "Rétablir le son", - "video_player.expand": "Agrandir la vidéo", - "video_player.toggle_sound": "Activer/Désactiver le son", - "video_player.toggle_visible": "Afficher/Cacher la vidéo", - "video_player.video_error": "Erreur lors de la lecture de la vidéo" + "video.unmute": "Rétablir le son" } diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index f78c31a46..beaa4fd3a 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -165,6 +165,11 @@ "report.submit": "שליחה", "report.target": "דיווח", "search.placeholder": "חיפוש", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "לא ניתן להדהד הודעה זו", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "התראות", "upload_area.title": "ניתן להעלות על ידי Drag & drop", "upload_button.label": "הוספת מדיה", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "ביטול", "upload_progress.label": "עולה...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "הרחבת וידאו", - "video_player.toggle_sound": "הפעלת\\ביטול שמע", - "video_player.toggle_visible": "הפעלת\\ביטול תצוגה", - "video_player.video_error": "לא ניתן לנגן וידאו" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index 43fe95eb8..cef61f15e 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -165,6 +165,11 @@ "report.submit": "Pošalji", "report.target": "Prijavljivanje", "search.placeholder": "Traži", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Ovaj post ne može biti boostan", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notifikacije", "upload_area.title": "Povuci i spusti kako bi uploadao", "upload_button.label": "Dodaj media", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Poništi", "upload_progress.label": "Uploadam...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Proširi video", - "video_player.toggle_sound": "Toggle zvuk", - "video_player.toggle_visible": "Preklopi vidljivost", - "video_player.video_error": "Video ne može biti reproduciran" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index f73295dca..7b9c1b293 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Keresés", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notifications", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Média hozzáadása", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Mégsem", "upload_progress.label": "Uploading...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Expand video", - "video_player.toggle_sound": "Hang kapcsolása", - "video_player.toggle_visible": "Toggle visibility", - "video_player.video_error": "Video could not be played" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index 4d5f0a5d8..cc48aa996 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -165,6 +165,11 @@ "report.submit": "Kirim", "report.target": "Melaporkan", "search.placeholder": "Pencarian", + "search_popout.search_format": "Advanced search format", + "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.total": "{count} {count, plural, one {hasil} other {hasil}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notifikasi", "upload_area.title": "Seret & lepaskan untuk mengunggah", "upload_button.label": "Tambahkan media", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Undo", "upload_progress.label": "Mengunggah...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Tampilkan video", - "video_player.toggle_sound": "Suara", - "video_player.toggle_visible": "Tampilan", - "video_player.video_error": "Video tidak dapat diputar" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index d2c1ee73d..b484bebc7 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -165,6 +165,11 @@ "report.submit": "Sendar", "report.target": "Denuncante", "search.placeholder": "Serchez", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Savigi", "upload_area.title": "Tranar faligar por kargar", "upload_button.label": "Adjuntar kontenajo", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Desfacar", "upload_progress.label": "Kargante...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Extensar video", - "video_player.toggle_sound": "Acendar sono", - "video_player.toggle_visible": "Chanjar videbleso", - "video_player.video_error": "Video ne povus pleesar" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 33f0e7fdc..4d73fbea8 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -165,6 +165,11 @@ "report.submit": "Invia", "report.target": "Invio la segnalazione", "search.placeholder": "Cerca", + "search_popout.search_format": "Advanced search format", + "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.total": "{count} {count, plural, one {risultato} other {risultati}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notifiche", "upload_area.title": "Trascina per caricare", "upload_button.label": "Aggiungi file multimediale", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Annulla", "upload_progress.label": "Sto caricando...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Espandi video", - "video_player.toggle_sound": "Attiva suono", - "video_player.toggle_visible": "Attiva visibilità", - "video_player.video_error": "Il video non può essere riprodotto" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index c3d96baf3..ce797a7c7 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -66,17 +66,17 @@ "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。", "embed.preview": "表示例:", "emoji_button.activity": "活動", - "emoji_button.custom": "Custom", + "emoji_button.custom": "カスタム絵文字", "emoji_button.flags": "国旗", "emoji_button.food": "食べ物", "emoji_button.label": "絵文字を追加", "emoji_button.nature": "自然", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", + "emoji_button.not_found": "絵文字がない!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "物", "emoji_button.people": "人々", - "emoji_button.recent": "Frequently used", + "emoji_button.recent": "よく使う絵文字", "emoji_button.search": "検索...", - "emoji_button.search_results": "Search results", + "emoji_button.search_results": "検索結果", "emoji_button.symbols": "記号", "emoji_button.travel": "旅行と場所", "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!", @@ -160,11 +160,21 @@ "privacy.public.short": "公開", "privacy.unlisted.long": "公開TLで表示しない", "privacy.unlisted.short": "未収載", + "relative_time.days": "{number}日前", + "relative_time.hours": "{number}時間前", + "relative_time.just_now": "今", + "relative_time.minutes": "{number}分前", + "relative_time.seconds": "{number}秒前", "reply_indicator.cancel": "キャンセル", "report.placeholder": "コメント", "report.submit": "通報する", "report.target": "{target} を通報する", "search.placeholder": "検索", + "search_popout.search_format": "高度な検索フォーマット", + "search_popout.tips.hashtag": "ハッシュタグ", + "search_popout.tips.status": "トゥート", + "search_popout.tips.text": "表示名やユーザー名、ハッシュタグに一致する単純なテキスト", + "search_popout.tips.user": "ユーザー", "search_results.total": "{count, number}件の結果", "standalone.public_title": "今こんな話をしています", "status.cannot_reblog": "この投稿はブーストできません", @@ -196,6 +206,7 @@ "tabs_bar.notifications": "通知", "upload_area.title": "ドラッグ&ドロップでアップロード", "upload_button.label": "メディアを追加", + "upload_form.description": "視覚障害者のための説明", "upload_form.undo": "やり直す", "upload_progress.label": "アップロード中...", "video.close": "動画を閉じる", @@ -206,9 +217,5 @@ "video.mute": "ミュート", "video.pause": "一時停止", "video.play": "再生", - "video.unmute": "ミュートを解除する", - "video_player.expand": "動画の詳細", - "video_player.toggle_sound": "音の切り替え", - "video_player.toggle_visible": "表示切り替え", - "video_player.video_error": "動画の再生に失敗しました" + "video.unmute": "ミュートを解除する" } diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index c50bb2f34..c1768cf8f 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -165,6 +165,11 @@ "report.submit": "신고하기", "report.target": "문제가 된 사용자", "search.placeholder": "검색", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number}건의 결과", "standalone.public_title": "A look inside...", "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "알림", "upload_area.title": "드래그 & 드롭으로 업로드", "upload_button.label": "미디어 추가", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "재시도", "upload_progress.label": "업로드 중...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "동영상 자세히 보기", - "video_player.toggle_sound": "소리 토글하기", - "video_player.toggle_visible": "표시 전환", - "video_player.video_error": "동영상 재생에 실패했습니다" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index c333bec70..bad2d78c5 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -165,6 +165,11 @@ "report.submit": "Verzenden", "report.target": "Rapporteren van", "search.placeholder": "Zoeken", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}", "standalone.public_title": "Een kijkje binnenin...", "status.cannot_reblog": "Deze toot kan niet geboost worden", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Meldingen", "upload_area.title": "Hierin slepen om te uploaden", "upload_button.label": "Media toevoegen", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Ongedaan maken", "upload_progress.label": "Uploaden...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Geluid uitschakelen", "video.pause": "Pauze", "video.play": "Afspelen", - "video.unmute": "Geluid inschakelen", - "video_player.expand": "Video groter maken", - "video_player.toggle_sound": "Geluid in-/uitschakelen", - "video_player.toggle_visible": "Video wel/niet tonen", - "video_player.video_error": "Video kon niet afgespeeld worden" + "video.unmute": "Geluid inschakelen" } diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index d28190faf..26556b290 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -165,6 +165,11 @@ "report.submit": "Send inn", "report.target": "Rapporterer", "search.placeholder": "Søk", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {resultat} other {resultater}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Denne posten kan ikke fremheves", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Varslinger", "upload_area.title": "Dra og slipp for å laste opp", "upload_button.label": "Legg til media", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Angre", "upload_progress.label": "Laster opp...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Utvid video", - "video_player.toggle_sound": "Veksle lyd", - "video_player.toggle_visible": "Veksle synlighet", - "video_player.video_error": "Video kunne ikke spilles av" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 8e9d06642..d730b47f4 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -165,6 +165,11 @@ "report.submit": "Mandar", "report.target": "Senhalar {target}", "search.placeholder": "Recercar", + "search_popout.search_format": "Format recèrca avançada", + "search_popout.tips.hashtag": "etiqueta", + "search_popout.tips.status": "estatut", + "search_popout.tips.text": "Tèxt brut tòrna escais, noms d’utilizaire e etiquetas correspondents", + "search_popout.tips.user": "utilizaire", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}", "standalone.public_title": "Una ulhada dedins…", "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notificacions", "upload_area.title": "Lisatz e depausatz per mandar", "upload_button.label": "Ajustar un mèdia", + "upload_form.description": "Descripcion se per cas i aja un problèma", "upload_form.undo": "Anullar", "upload_progress.label": "Mandadís…", "video.close": "Tampar la vidèo", @@ -206,9 +212,5 @@ "video.mute": "Copar lo son", "video.pause": "Pausa", "video.play": "Lectura", - "video.unmute": "Restablir lo son", - "video_player.expand": "Mostrar la vidèo", - "video_player.toggle_sound": "Activar/Desactivar lo son", - "video_player.toggle_visible": "Mostrar/Rescondre la vidèo", - "video_player.video_error": "Fracàs de la lectura de la vidèo" + "video.unmute": "Restablir lo son" } diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 35b1a3101..c8228c0cb 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -56,14 +56,14 @@ "confirmations.block.confirm": "Zablokuj", "confirmations.block.message": "Czy na pewno chcesz zablokować {name}?", "confirmations.delete.confirm": "Usuń", - "confirmations.delete.message": "Czy na pewno chcesz usunąć ten status?", + "confirmations.delete.message": "Czy na pewno chcesz usunąć ten wpis?", "confirmations.domain_block.confirm": "Ukryj wszysyko z domeny", "confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.", "confirmations.mute.confirm": "Wycisz", "confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?", "confirmations.unfollow.confirm": "Przestań śledzić", "confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?", - "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.", + "embed.instructions": "Osadź ten wpis na swojej stronie wklejając poniższy kod.", "embed.preview": "Tak będzie to wyglądać:", "emoji_button.activity": "Aktywność", "emoji_button.custom": "Niestandardowe", @@ -116,10 +116,10 @@ "navigation_bar.pins": "Przypięte wpisy", "navigation_bar.preferences": "Preferencje", "navigation_bar.public_timeline": "Oś czasu federacji", - "notification.favourite": "{name} dodał Twój status do ulubionych", + "notification.favourite": "{name} dodał Twój wpis do ulubionych", "notification.follow": "{name} zaczął Cię śledzić", "notification.mention": "{name} wspomniał o tobie", - "notification.reblog": "{name} podbił Twój status", + "notification.reblog": "{name} podbił Twój wpis", "notifications.clear": "Wyczyść powiadomienia", "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?", "notifications.column_settings.alert": "Powiadomienia na pulpicie", @@ -165,6 +165,11 @@ "report.submit": "Wyślij", "report.target": "Zgłaszanie {target}", "search.placeholder": "Szukaj", + "search_popout.search_format": "Zaawansowane wyszukiwanie", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "wpis", + "search_popout.tips.text": "Proste wyszukiwanie pasujących pseudonimów, nazw użytkowników i hashtagów", + "search_popout.tips.user": "użytkownik", "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}", "standalone.public_title": "Spojrzenie w głąb…", "status.cannot_reblog": "Ten wpis nie może zostać podbity", @@ -175,7 +180,7 @@ "status.media_hidden": "Zawartość multimedialna ukryta", "status.mention": "Wspomnij o @{name}", "status.mute_conversation": "Wycisz konwersację", - "status.open": "Rozszerz ten status", + "status.open": "Rozszerz ten wpis", "status.pin": "Przypnij do profilu", "status.reblog": "Podbij", "status.reblogged_by": "{name} podbił", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Powiadomienia", "upload_area.title": "Przeciągnij i upuść aby wysłać", "upload_button.label": "Dodaj zawartość multimedialną", + "upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących", "upload_form.undo": "Cofnij", "upload_progress.label": "Wysyłanie", "video.close": "Zamknij film", @@ -206,9 +212,5 @@ "video.mute": "Wycisz", "video.pause": "Pauzuj", "video.play": "Odtwórz", - "video.unmute": "Cofnij wyciszenie", - "video_player.expand": "Rozszerz film", - "video_player.toggle_sound": "Przełącz dźwięk", - "video_player.toggle_visible": "Przełącz widoczność", - "video_player.video_error": "Nie można odtworzyć pliku wideo" + "video.unmute": "Cofnij wyciszenie" } diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 187343e83..61674b37e 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -165,6 +165,11 @@ "report.submit": "Enviar", "report.target": "Denunciar", "search.placeholder": "Pesquisar", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "standalone.public_title": "Dê uma espiada...", "status.cannot_reblog": "Esta postagem não pode ser compartilhada", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notificações", "upload_area.title": "Arraste e solte para enviar", "upload_button.label": "Adicionar mídia", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Anular", "upload_progress.label": "Salvando...", "video.close": "Fechar vídeo", @@ -206,9 +212,5 @@ "video.mute": "Silenciar vídeo", "video.pause": "Parar", "video.play": "Reproduzir", - "video.unmute": "Retirar silêncio", - "video_player.expand": "Expandir vídeo", - "video_player.toggle_sound": "Ligar/Desligar som", - "video_player.toggle_visible": "Ligar/Desligar vídeo", - "video_player.video_error": "Não é possível ver o vídeo" + "video.unmute": "Retirar silêncio" } diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index 782aaf114..ecd0689df 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -165,6 +165,11 @@ "report.submit": "Enviar", "report.target": "Denunciar", "search.placeholder": "Pesquisar", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notificações", "upload_area.title": "Arraste e solte para enviar", "upload_button.label": "Adicionar media", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Anular", "upload_progress.label": "A gravar...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Expandir vídeo", - "video_player.toggle_sound": "Ligar/Desligar som", - "video_player.toggle_visible": "Ligar/Desligar vídeo", - "video_player.video_error": "Não é possível ver o vídeo" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 6f39d098c..bf32c820d 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -165,6 +165,11 @@ "report.submit": "Отправить", "report.target": "Жалуемся на", "search.placeholder": "Поиск", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Этот статус не может быть продвинут", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Уведомления", "upload_area.title": "Перетащите сюда, чтобы загрузить", "upload_button.label": "Добавить медиаконтент", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Отменить", "upload_progress.label": "Загрузка...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Развернуть видео", - "video_player.toggle_sound": "Вкл./выкл. звук", - "video_player.toggle_visible": "Показать/скрыть", - "video_player.video_error": "Видео не может быть проиграно" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index ecc7a00db..f3ec9c532 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Search", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Notifications", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Add media", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Undo", "upload_progress.label": "Uploading...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Expand video", - "video_player.toggle_sound": "Toggle sound", - "video_player.toggle_visible": "Toggle visibility", - "video_player.video_error": "Video could not be played" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index b7ecd2cdb..afc6383b4 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -165,6 +165,11 @@ "report.submit": "Gönder", "report.target": "Raporlama", "search.placeholder": "Ara", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Bu gönderi boost edilemez", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Bildirimler", "upload_area.title": "Upload için sürükle bırak yapınız", "upload_button.label": "Görsel ekle", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Geri al", "upload_progress.label": "Yükleniyor...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Videoyu genişlet", - "video_player.toggle_sound": "Sesi aç/kapa", - "video_player.toggle_visible": "Göster/gizle", - "video_player.video_error": "Video oynatılamadı" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index 45b2c2ee0..d0aae032b 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -165,6 +165,11 @@ "report.submit": "Відправити", "report.target": "Скаржимося на", "search.placeholder": "Пошук", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Цей допис не може бути передмухнутий", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "Сповіщення", "upload_area.title": "Перетягніть сюди, щоб завантажити", "upload_button.label": "Додати медіаконтент", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "Відмінити", "upload_progress.label": "Завантаження...", "video.close": "Close video", @@ -206,9 +212,5 @@ "video.mute": "Mute sound", "video.pause": "Pause", "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Розгорнути ", - "video_player.toggle_sound": "Увімкнути/вимкнути звук", - "video_player.toggle_visible": "Показати/приховати", - "video_player.video_error": "Відео не може бути відтворено" + "video.unmute": "Unmute sound" } diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 58e3d6780..e0ffc16df 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -165,6 +165,11 @@ "report.submit": "提交", "report.target": "Reporting", "search.placeholder": "搜索", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "大家都在干啥?", "status.cannot_reblog": "没法转嘟这条嘟文啦……", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "通知", "upload_area.title": "将文件拖放至此上传", "upload_button.label": "上传媒体文件", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "还原", "upload_progress.label": "上传中……", "video.close": "关闭影片", @@ -206,9 +212,5 @@ "video.mute": "静音", "video.pause": "暂停", "video.play": "播放", - "video.unmute": "解除静音", - "video_player.expand": "展开影片", - "video_player.toggle_sound": "开关音效", - "video_player.toggle_visible": "打开或关上", - "video_player.video_error": "视频无法播放啦……" + "video.unmute": "解除静音" } diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 610aa6daf..053e971aa 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -165,6 +165,11 @@ "report.submit": "提交", "report.target": "舉報", "search.placeholder": "搜尋", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} 項結果", "standalone.public_title": "站點一瞥…", "status.cannot_reblog": "這篇文章無法被轉推", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "通知", "upload_area.title": "將檔案拖放至此上載", "upload_button.label": "上載媒體檔案", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "還原", "upload_progress.label": "上載中……", "video.close": "關閉影片", @@ -206,9 +212,5 @@ "video.mute": "靜音", "video.pause": "暫停", "video.play": "播放", - "video.unmute": "解除靜音", - "video_player.expand": "展開影片", - "video_player.toggle_sound": "開關音效", - "video_player.toggle_visible": "打開或關上", - "video_player.video_error": "無法播放影片" + "video.unmute": "解除靜音" } diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index ad2f1a05a..a22d66fa1 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -165,6 +165,11 @@ "report.submit": "送出", "report.target": "通報中", "search.placeholder": "搜尋", + "search_popout.search_format": "Advanced search format", + "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.total": "{count, number} 項結果", "standalone.public_title": "站點一瞥…", "status.cannot_reblog": "此貼文無法轉推", @@ -196,6 +201,7 @@ "tabs_bar.notifications": "通知", "upload_area.title": "拖放來上傳", "upload_button.label": "增加媒體", + "upload_form.description": "Describe for the visually impaired", "upload_form.undo": "復原", "upload_progress.label": "上傳中...", "video.close": "關閉影片", @@ -206,9 +212,5 @@ "video.mute": "消音", "video.pause": "暫停", "video.play": "播放", - "video.unmute": "解除消音", - "video_player.expand": "展開影片", - "video_player.toggle_sound": "切換音效", - "video_player.toggle_visible": "切換可見性", - "video_player.video_error": "無法播放這影片" + "video.unmute": "解除消音" } diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/performance.js index 396c605e4..450a90626 100644 --- a/app/javascript/mastodon/performance.js +++ b/app/javascript/mastodon/performance.js @@ -14,8 +14,8 @@ if (process.env.NODE_ENV === 'development') { } marky = require('marky'); // allows us to easily do e.g. ReactPerf.printWasted() while debugging - window.ReactPerf = require('react-addons-perf'); - window.ReactPerf.start(); + //window.ReactPerf = require('react-addons-perf'); + //window.ReactPerf.start(); } export function start(name) { diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js index 5391a93ae..8a4d69f26 100644 --- a/app/javascript/mastodon/reducers/accounts.js +++ b/app/javascript/mastodon/reducers/accounts.js @@ -44,7 +44,7 @@ import { FAVOURITED_STATUSES_EXPAND_SUCCESS, } from '../actions/favourites'; import { STORE_HYDRATE } from '../actions/store'; -import emojify from '../emoji'; +import emojify from '../features/emoji/emoji'; import { Map as ImmutableMap, fromJS } from 'immutable'; import escapeTextContentForBrowser from 'escape-html'; diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 5756a393f..b1d590748 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -23,6 +23,10 @@ import { COMPOSE_VISIBILITY_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_RESET, } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; @@ -227,6 +231,7 @@ export default function compose(state = initialState, action) { } }); case COMPOSE_REPLY_CANCEL: + case COMPOSE_RESET: return state.withMutations(map => { map.set('in_reply_to', null); map.set('text', ''); @@ -237,15 +242,15 @@ export default function compose(state = initialState, action) { map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: + case COMPOSE_UPLOAD_CHANGE_REQUEST: return state.set('is_submitting', true); case COMPOSE_SUBMIT_SUCCESS: return clearAll(state); case COMPOSE_SUBMIT_FAIL: + case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_submitting', false); case COMPOSE_UPLOAD_REQUEST: - return state.withMutations(map => { - map.set('is_uploading', true); - }); + return state.set('is_uploading', true); case COMPOSE_UPLOAD_SUCCESS: return appendMedia(state, fromJS(action.media)); case COMPOSE_UPLOAD_FAIL: @@ -273,6 +278,16 @@ export default function compose(state = initialState, action) { } case COMPOSE_EMOJI_INSERT: return insertEmoji(state, action.position, action.emoji); + case COMPOSE_UPLOAD_CHANGE_SUCCESS: + return state + .set('is_submitting', false) + .update('media_attachments', list => list.map(item => { + if (item.get('id') === action.media.id) { + return item.set('description', action.media.description); + } + + return item; + })); default: return state; } diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js index 9bfc09aa7..64d584a01 100644 --- a/app/javascript/mastodon/reducers/contexts.js +++ b/app/javascript/mastodon/reducers/contexts.js @@ -1,6 +1,6 @@ import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; -import { TIMELINE_DELETE } from '../actions/timelines'; -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; const initialState = ImmutableMap({ ancestors: ImmutableMap(), @@ -8,8 +8,8 @@ const initialState = ImmutableMap({ }); const normalizeContext = (state, id, ancestors, descendants) => { - const ancestorsIds = ancestors.map(ancestor => ancestor.get('id')); - const descendantsIds = descendants.map(descendant => descendant.get('id')); + const ancestorsIds = ImmutableList(ancestors.map(ancestor => ancestor.id)); + const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id)); return state.withMutations(map => { map.setIn(['ancestors', id], ancestorsIds); @@ -31,12 +31,30 @@ const deleteFromContexts = (state, id) => { return state; }; +const updateContext = (state, status, references) => { + return state.update('descendants', map => { + references.forEach(parentId => { + map = map.update(parentId, ImmutableList(), list => { + if (list.includes(status.id)) { + return list; + } + + return list.push(status.id); + }); + }); + + return map; + }); +}; + export default function contexts(state = initialState, action) { switch(action.type) { case CONTEXT_FETCH_SUCCESS: - return normalizeContext(state, action.id, fromJS(action.ancestors), fromJS(action.descendants)); + return normalizeContext(state, action.id, action.ancestors, action.descendants); case TIMELINE_DELETE: return deleteFromContexts(state, action.id); + case TIMELINE_CONTEXT_UPDATE: + return updateContext(state, action.status, action.references); default: return state; } diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js index d80c0d156..f2a8ca5d2 100644 --- a/app/javascript/mastodon/reducers/custom_emojis.js +++ b/app/javascript/mastodon/reducers/custom_emojis.js @@ -1,14 +1,14 @@ import { List as ImmutableList } from 'immutable'; import { STORE_HYDRATE } from '../actions/store'; -import { emojiIndex } from 'emoji-mart'; -import { buildCustomEmojis } from '../emoji'; +import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +import { buildCustomEmojis } from '../features/emoji/emoji'; const initialState = ImmutableList(); export default function custom_emojis(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); + emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', []), action.state.getIn(['meta', 'auto_play_gif'], false)) }); return action.state.get('custom_emojis'); default: return state; diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index ecce8dcb6..48850ab01 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -15,7 +15,10 @@ import { NOTIFICATIONS_ENTER_CLEARING_MODE, NOTIFICATIONS_MARK_ALL_FOR_DELETE, } from '../actions/notifications'; -import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from '../actions/accounts'; import { TIMELINE_DELETE } from '../actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; @@ -151,6 +154,7 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_EXPAND_SUCCESS: return appendNormalizedNotifications(state, action.notifications, action.next); case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: return filterNotifications(state, action.relationship); case NOTIFICATIONS_CLEAR: return state.set('items', ImmutableList()).set('next', null); diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 1bdee7356..0c0dae388 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -1,13 +1,18 @@ -import { SETTING_CHANGE } from '../actions/settings'; +import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings'; import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns'; import { STORE_HYDRATE } from '../actions/store'; +import { EMOJI_USE } from '../actions/emojis'; import { Map as ImmutableMap, fromJS } from 'immutable'; import uuid from '../uuid'; const initialState = ImmutableMap({ + saved: true, + onboarded: false, layout: 'auto', + skinTone: 1, + home: ImmutableMap({ shows: ImmutableMap({ reblog: true, @@ -73,21 +78,35 @@ const moveColumn = (state, uuid, direction) => { newColumns = columns.splice(index, 1); newColumns = newColumns.splice(newIndex, 0, columns.get(index)); - return state.set('columns', newColumns); + return state + .set('columns', newColumns) + .set('saved', false); }; +const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false); + export default function settings(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: return hydrate(state, action.state.get('settings')); case SETTING_CHANGE: - return state.setIn(action.key, action.value); + return state + .setIn(action.key, action.value) + .set('saved', false); case COLUMN_ADD: - return state.update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params }))); + return state + .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params }))) + .set('saved', false); case COLUMN_REMOVE: - return state.update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid)); + return state + .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid)) + .set('saved', false); case COLUMN_MOVE: return moveColumn(state, action.uuid, action.direction); + case EMOJI_USE: + return updateFrequentEmojis(state, action.emoji); + case SETTING_SAVE: + return state.set('saved', true); default: return state; } diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 38b23504e..b1fb4c5da 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -24,6 +24,7 @@ import { } from '../actions/timelines'; import { ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, } from '../actions/accounts'; import { NOTIFICATIONS_UPDATE, @@ -38,7 +39,7 @@ import { PINNED_STATUSES_FETCH_SUCCESS, } from '../actions/pin_statuses'; import { SEARCH_FETCH_SUCCESS } from '../actions/search'; -import emojify from '../emoji'; +import emojify from '../features/emoji/emoji'; import { Map as ImmutableMap, fromJS } from 'immutable'; import escapeTextContentForBrowser from 'escape-html'; @@ -57,9 +58,10 @@ const normalizeStatus = (state, status) => { normalStatus.reblog = status.reblog.id; } - const searchContent = [status.spoiler_text, status.content].join(' ').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + 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.url; + obj[`:${emoji.shortcode}:`] = emoji; return obj; }, {}); @@ -138,6 +140,7 @@ export default function statuses(state = initialState, action) { case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: return filterStatuses(state, action.relationship); default: return state; diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js index 6705377c1..50c81198e 100644 --- a/app/javascript/packs/about.js +++ b/app/javascript/packs/about.js @@ -4,9 +4,9 @@ require.context('../images/', true); function loaded() { const TimelineContainer = require('../mastodon/containers/timeline_container').default; - const React = require('react'); - const ReactDOM = require('react-dom'); - const mountNode = document.getElementById('mastodon-timeline'); + const React = require('react'); + const ReactDOM = require('react-dom'); + const mountNode = document.getElementById('mastodon-timeline'); if (mountNode !== null) { const props = JSON.parse(mountNode.getAttribute('data-props')); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 8842d6dcb..59d0e98dd 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -22,7 +22,7 @@ function main() { const { length } = require('stringz'); const IntlRelativeFormat = require('intl-relativeformat').default; const { delegate } = require('rails-ujs'); - const emojify = require('../mastodon/emoji').default; + const emojify = require('../mastodon/features/emoji/emoji').default; const { getLocale } = require('../mastodon/locales'); const { localeData } = getLocale(); const VideoContainer = require('../mastodon/containers/video_container').default; diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index 2adcb5ba2..a15afc32c 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -481,6 +481,7 @@ flex: 0 0 auto; background: $ui-base-color; overflow: hidden; + border-radius: 4px; box-shadow: 0 0 6px rgba($black, 0.1); .column-header { @@ -703,8 +704,98 @@ .features #mastodon-timeline { height: 70vh; width: 100%; + min-width: 330px; margin-bottom: 50px; + + .column { + width: 100%; + } + } + } + + .cta { + margin: 20px; + } + + &.tag-page { + .brand { + padding-top: 20px; + margin-bottom: 20px; + + img { + height: 48px; + width: auto; + } + } + + .container { + max-width: 690px; + } + + .cta { + margin: 40px 0; + margin-bottom: 80px; + + .button { + margin-right: 4px; + } + } + + .about-mastodon { + max-width: 330px; + + p { + strong { + color: $ui-secondary-color; + font-weight: 700; + } + } } + + @media screen and (max-width: 675px) { + .container { + display: flex; + flex-direction: column; + } + + .features { + padding: 20px 0; + } + + .about-mastodon { + order: 1; + flex: 0 0 auto; + max-width: 100%; + } + + #mastodon-timeline { + order: 2; + flex: 0 0 auto; + height: 60vh; + } + + .cta { + margin: 20px 0; + margin-bottom: 30px; + } + + .features-list { + display: none; + } + + .stripe { + display: none; + } + } + } + + .stripe { + width: 100%; + height: 360px; + overflow: hidden; + background: darken($ui-base-color, 4%); + position: absolute; + z-index: -1; } } diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss index 9d1e322b9..e2db9992d 100644 --- a/app/javascript/styles/accounts.scss +++ b/app/javascript/styles/accounts.scss @@ -69,12 +69,16 @@ position: relative; z-index: 2; margin-bottom: 30px; + overflow: hidden; + text-overflow: ellipsis; small { display: block; font-size: 14px; color: $ui-highlight-color; font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; } } @@ -328,21 +332,15 @@ color: lighten($ui-base-color, 10%); } - @media screen and (max-width: 360px) { + @media screen and (max-width: 700px) { padding: 30px 20px; - a, - .current, - .next, - .prev, - .gap { + .page { display: none; } .next, - .prev, - .next a, - .prev a { + .prev { display: inline-block; } } @@ -419,6 +417,7 @@ height: 80px; border-radius: 80px; border: 2px solid $simple-background-color; + background: $simple-background-color; } } diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss index 96f0023c3..b829191ad 100644 --- a/app/javascript/styles/basics.scss +++ b/app/javascript/styles/basics.scss @@ -29,7 +29,8 @@ body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif; } - &.app-body { + &.app-body, + &.error { position: fixed; width: 100%; height: 100%; @@ -42,6 +43,11 @@ body { padding-bottom: 0; } + &.tag-body { + background: darken($ui-base-color, 8%); + padding-bottom: 0; + } + &.embed { background: transparent; margin: 0; @@ -67,13 +73,16 @@ body { text-align: center; color: $ui-primary-color; padding: 20px; + display: flex; + justify-content: center; + align-items: center; .dialog img { display: block; - margin: 0 auto; max-width: 470px; width: 100%; height: auto; + margin-top: -120px; } .dialog h1 { @@ -94,9 +103,12 @@ button { } .app-holder { - display: flex; - width: 100%; - height: 100%; - align-items: center; - justify-content: center; + &, + & > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + } } diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 2f02af098..8ecc0b91b 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -75,6 +75,7 @@ text-transform: none; background: transparent; padding: 3px 15px; + border-radius: 4px; border: 1px solid $ui-primary-color; &:active, @@ -344,12 +345,57 @@ .compose-form__uploads-wrapper { display: flex; + flex-direction: row; padding: 5px; + flex-wrap: wrap; } .compose-form__upload { flex: 1 1 0; + min-width: 40%; margin: 5px; + + &-description { + position: absolute; + z-index: 2; + bottom: 0; + left: 0; + right: 0; + box-sizing: border-box; + background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent); + padding: 10px; + opacity: 0; + transition: opacity .1s ease; + + input { + background: transparent; + color: $ui-secondary-color; + border: 0; + padding: 0; + margin: 0; + width: 100%; + font-family: inherit; + font-size: 14px; + font-weight: 500; + + &:focus { + color: $white; + } + + &::placeholder { + opacity: 0.54; + color: $ui-secondary-color; + } + } + + &.active { + opacity: 1; + } + } + + .icon-button { + mix-blend-mode: difference; + } } .compose-form__upload-thumbnail { @@ -361,13 +407,6 @@ width: 100%; } -.compose-form__upload-cancel { - background-size: cover; - border-radius: 4px; - height: 100px; - width: 100px; -} - .compose-form__label { display: block; line-height: 24px; @@ -614,6 +653,22 @@ } } +.focusable { + &:focus { + outline: 0; + background: lighten($ui-base-color, 4%); + + &.status-direct { + background: lighten($ui-base-color, 12%); + } + + .detailed-status, + .detailed-status__action-bar { + background: lighten($ui-base-color, 8%); + } + } +} + .status { padding: 8px 10px; position: relative; @@ -792,6 +847,12 @@ .status__display-name strong { color: $ui-base-lighter-color; } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } } .status__action-bar { @@ -978,9 +1039,12 @@ .account__header__display-name { color: $primary-text-color; display: inline-block; + width: 100%; font-size: 20px; line-height: 27px; font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; } .account__header__username { @@ -989,6 +1053,8 @@ font-weight: 400; display: block; margin-bottom: 10px; + overflow: hidden; + text-overflow: ellipsis; } } @@ -1199,8 +1265,16 @@ } } +.muted { + .emojione { + opacity: 0.5; + } +} + .account__display-name strong { display: block; + overflow: hidden; + text-overflow: ellipsis; } .detailed-status__application, @@ -1275,6 +1349,12 @@ .fa { color: $ui-highlight-color; } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } } .notification__favourite-icon-wrapper { @@ -1403,11 +1483,14 @@ .navigation-bar__profile { flex: 1 1 auto; margin-left: 8px; + overflow: hidden; } .navigation-bar__profile-account { display: block; font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; } .navigation-bar__profile-edit { @@ -1434,7 +1517,7 @@ background: $ui-secondary-color; padding: 4px 0; border-radius: 4px; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.4); + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); ul { list-style: none; @@ -1851,6 +1934,16 @@ &.optionally-scrollable { overflow-y: auto; } + + @supports(display: grid) { // hack to fix Chrome <57 + contain: strict; + } +} + +.scrollable.fullscreen { + @supports(display: grid) { // hack to fix Chrome <57 + contain: none; + } } .column-back-button { @@ -2280,22 +2373,9 @@ button.icon-button.active i.fa-retweet { } .status-card-video { - position: relative; - width: 100%; - height: auto; - padding-top: 56.25%; - iframe { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - width: 1px; - min-width: 100%; - height: 1px; - min-height: 100%; - margin: auto; + width: 100%; + height: 100%; } } @@ -2876,19 +2956,36 @@ button.icon-button.active i.fa-retweet { flex-direction: column; } -@keyframes pulse { - 0% { - opacity: 1; +@keyframes heartbeat { + from { + transform: scale(1); + transform-origin: center center; + animation-timing-function: ease-out; } - 100% { - opacity: 0.5; + 10% { + transform: scale(0.91); + animation-timing-function: ease-in; + } + + 17% { + transform: scale(0.98); + animation-timing-function: ease-out; + } + + 33% { + transform: scale(0.87); + animation-timing-function: ease-in; + } + + 45% { + transform: scale(1); + animation-timing-function: ease-out; } } .pulse-loading { - animation: pulse 1s ease-in-out infinite; - animation-direction: alternate; + animation: heartbeat 1.5s ease-in-out infinite both; } .emoji-picker-dropdown__menu { @@ -3081,19 +3178,12 @@ button.icon-button.active i.fa-retweet { filter: none; } -.privacy-dropdown { - position: relative; -} - .privacy-dropdown__dropdown { - display: none; position: absolute; - left: 0; - top: 27px; - width: 230px; background: $simple-background-color; - border-radius: 0 4px 4px; - z-index: 2; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 4px; + margin-left: 40px; overflow: hidden; } @@ -3145,6 +3235,18 @@ button.icon-button.active i.fa-retweet { background: $simple-background-color; border-radius: 4px 4px 0 0; box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); + + .icon-button { + transition: none; + } + + &.active { + background: $ui-highlight-color; + + .icon-button { + color: $primary-text-color; + } + } } .privacy-dropdown__dropdown { @@ -3337,14 +3439,18 @@ button.icon-button.active i.fa-retweet { } } +.modal-root { + transition: opacity 0.3s linear; + will-change: opacity; + z-index: 9999; +} + .modal-root__overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; - z-index: 9999; - opacity: 0; background: rgba($base-overlay-background, 0.7); transform: translateZ(0); } @@ -3361,7 +3467,6 @@ button.icon-button.active i.fa-retweet { justify-content: center; align-content: space-around; z-index: 9999; - opacity: 0; pointer-events: none; user-select: none; } @@ -3411,6 +3516,33 @@ button.icon-button.active i.fa-retweet { background: $base-overlay-background; } +.media-modal__pagination { + width: 100%; + text-align: center; + position: absolute; + left: 0; + bottom: -40px; +} + +.media-modal__page-dot { + display: inline-block; +} + +.media-modal__button { + background-color: $white; + height: 12px; + width: 12px; + border-radius: 6px; + margin: 10px; + padding: 0; + border: 0; + font-size: 0; +} + +.media-modal__button--active { + background-color: $ui-highlight-color; +} + .media-modal__close { position: absolute; right: 4px; @@ -4293,7 +4425,8 @@ button.icon-button.active i.fa-retweet { top: 10px; } - &__progress { + &__progress, + &__buffer { display: block; position: absolute; height: 4px; @@ -4301,6 +4434,10 @@ button.icon-button.active i.fa-retweet { background: $ui-highlight-color; } + &__buffer { + background: rgba($white, 0.2); + } + &__handle { position: absolute; z-index: 3; @@ -4420,6 +4557,37 @@ button.icon-button.active i.fa-retweet { border-radius: 0; } +.search-popout { + background: $simple-background-color; + border-radius: 4px; + padding: 10px 14px; + padding-bottom: 14px; + margin-top: 10px; + color: $ui-primary-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + h4 { + text-transform: uppercase; + color: $ui-primary-color; + font-size: 13px; + font-weight: 500; + margin-bottom: 10px; + } + + li { + padding: 4px 0; + } + + ul { + margin-bottom: 10px; + } + + em { + font-weight: 500; + color: $ui-base-color; + } +} + noscript { text-align: center; diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss index 0526f174c..61fcf286f 100644 --- a/app/javascript/styles/forms.scss +++ b/app/javascript/styles/forms.scss @@ -22,6 +22,16 @@ code { margin-top: 4px; } + h4 { + text-transform: uppercase; + font-size: 13px; + font-weight: 500; + color: $ui-primary-color; + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + p.hint { margin-bottom: 15px; color: $ui-primary-color; @@ -316,6 +326,7 @@ code { select { font-size: 16px; + max-height: 29px; } .input-with-append { @@ -504,6 +515,7 @@ code { .action-pagination { display: flex; + flex-wrap: wrap; align-items: center; .actions, diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss index 0fdeccd9c..67bfa8a38 100644 --- a/app/javascript/styles/rtl.scss +++ b/app/javascript/styles/rtl.scss @@ -128,22 +128,8 @@ body.rtl { } .privacy-dropdown__dropdown { - left: auto; - right: 0; - } - - .dropdown--active .dropdown__content { - text-align: right; - } - - .dropdown--active .dropdown__content::before { - left: auto; - right: 8px; - } - - .dropdown--active .dropdown__content > ul { - left: auto; - right: -10px; + margin-left: 0; + margin-right: 40px; } .privacy-dropdown__option__icon { diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index b06dd6194..9688f57a6 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -3,10 +3,11 @@ class ActivityPub::Activity include JsonLdHelper - def initialize(json, account) + def initialize(json, account, options = {}) @json = json @account = account @object = @json['object'] + @options = options end def perform @@ -14,9 +15,9 @@ class ActivityPub::Activity end class << self - def factory(json, account) + def factory(json, account, options = {}) @json = json - klass&.new(json, account) + klass&.new(json, account, options) end private diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 4516454e1..b84098933 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -15,8 +15,9 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity account: @account, reblog: original_status, uri: @json['id'], - created_at: @json['published'] || Time.now.utc + created_at: @options[:override_timestamps] ? nil : @json['published'] ) + distribute(status) status end @@ -27,7 +28,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity if object_uri.start_with?('http') return if ActivityPub::TagManager.instance.local_uri?(object_uri) - ActivityPub::FetchRemoteStatusService.new.call(object_uri) + ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true) elsif @object['url'].present? ::FetchRemoteStatusService.new.call(@object['url']) end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4e19b3096..d6e9bc1de 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -43,7 +43,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity text: text_from_content || '', language: language_from_content, spoiler_text: @object['summary'] || '', - created_at: @object['published'] || Time.now.utc, + created_at: @options[:override_timestamps] ? nil : @object['published'], reply: @object['inReplyTo'].present?, sensitive: @object['sensitive'] || false, visibility: visibility_from_audience, @@ -80,21 +80,25 @@ class ActivityPub::Activity::Create < ActivityPub::Activity return if tag['href'].blank? account = account_from_uri(tag['href']) - account = FetchRemoteAccountService.new.call(tag['href']) if account.nil? + account = FetchRemoteAccountService.new.call(tag['href'], id: false) if account.nil? return if account.nil? account.mentions.create(status: status) end def process_emoji(tag, _status) - return if tag['name'].blank? || tag['href'].blank? + return if skip_download? + return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank? shortcode = tag['name'].delete(':') + image_url = tag['icon']['url'] + uri = tag['id'] + updated = tag['updated'] emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain) - return if !emoji.nil? || skip_download? + return unless emoji.nil? || emoji.updated_at >= updated - emoji = CustomEmoji.new(domain: @account.domain, shortcode: shortcode) - emoji.image_remote_url = tag['href'] + emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri) + emoji.image_remote_url = image_url emoji.save end @@ -105,7 +109,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? href = Addressable::URI.parse(attachment['url']).normalize.to_s - media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href) + media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href, description: attachment['name'].presence) next if skip_download? diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index adb8b6cdf..16142a6ff 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature return unless type == 'RsaSignature2017' creator = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account) - creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri) + creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) return if creator.nil? diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 4ec3b8c56..0708713e6 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -33,6 +33,8 @@ class ActivityPub::TagManager when :note, :comment, :activity return activity_account_status_url(target.account, target) if target.reblog? account_status_url(target.account, target) + when :emoji + emoji_url(target) end end diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb new file mode 100644 index 000000000..8d3be35de --- /dev/null +++ b/app/lib/delivery_failure_tracker.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class DeliveryFailureTracker + FAILURE_DAYS_THRESHOLD = 7 + + def initialize(inbox_url) + @inbox_url = inbox_url + end + + def track_failure! + Redis.current.sadd(exhausted_deliveries_key, today) + Redis.current.sadd('unavailable_inboxes', @inbox_url) if reached_failure_threshold? + end + + def track_success! + Redis.current.del(exhausted_deliveries_key) + Redis.current.srem('unavailable_inboxes', @inbox_url) + end + + def days + Redis.current.scard(exhausted_deliveries_key) || 0 + end + + class << self + def filter(arr) + arr.reject(&method(:unavailable?)) + end + + def unavailable?(url) + Redis.current.sismember('unavailable_inboxes', url) + end + + def available?(url) + !unavailable?(url) + end + + def track_inverse_success!(from_account) + new(from_account.inbox_url).track_success! if from_account.inbox_url.present? + new(from_account.shared_inbox_url).track_success! if from_account.shared_inbox_url.present? + end + end + + private + + def exhausted_deliveries_key + "exhausted_deliveries:#{@inbox_url}" + end + + def today + Time.now.utc.strftime('%Y%m%d') + end + + def reached_failure_threshold? + days >= FAILURE_DAYS_THRESHOLD + end +end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 3b6796142..f6a694135 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -7,8 +7,13 @@ class FeedManager MAX_ITEMS = 400 - def key(type, id) - "feed:#{type}:#{id}" + # Must be <= MAX_ITEMS or the tracking sets will grow forever + REBLOG_FALLOFF = 40 + + def key(type, id, subtype = nil) + return "feed:#{type}:#{id}" unless subtype + + "feed:#{type}:#{id}:#{subtype}" end def filter?(timeline_type, status, receiver_id) @@ -22,23 +27,36 @@ class FeedManager end def push(timeline_type, account, status) - timeline_key = key(timeline_type, account.id) + return false unless add_to_feed(timeline_type, account, status) - if status.reblog? - # If the original status is within 40 statuses from top, do not re-insert it into the feed - rank = redis.zrevrank(timeline_key, status.reblog_of_id) - return if !rank.nil? && rank < 40 - redis.zadd(timeline_key, status.id, status.reblog_of_id) - else - redis.zadd(timeline_key, status.id, status.id) - trim(timeline_type, account.id) - end + trim(timeline_type, account.id) PushUpdateWorker.perform_async(account.id, status.id) if push_update_required?(timeline_type, account.id) + + true + end + + def unpush(timeline_type, account, status) + return false unless remove_from_feed(timeline_type, account, status) + + payload = Oj.dump(event: :delete, payload: status.id.to_s) + Redis.current.publish("timeline:#{account.id}", payload) + + true end def trim(type, account_id) - redis.zremrangebyrank(key(type, account_id), '0', (-(FeedManager::MAX_ITEMS + 1)).to_s) + timeline_key = key(type, account_id) + reblog_key = key(type, account_id, 'reblogs') + # Remove any items past the MAX_ITEMS'th entry in our feed + redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s) + + # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop + # tracking anything after it for deduplication purposes. + falloff_rank = FeedManager::REBLOG_FALLOFF - 1 + falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true) + falloff_score = falloff_range&.first&.last&.to_i || 0 + redis.zremrangebyscore(reblog_key, 0, falloff_score) end def push_update_required?(timeline_type, account_id) @@ -54,11 +72,9 @@ class FeedManager query = query.where('id > ?', oldest_home_score) end - redis.pipelined do - query.each do |status| - next if status.direct_visibility? || filter?(:home, status, into_account) - redis.zadd(timeline_key, status.id, status.id) - end + query.each do |status| + next if status.direct_visibility? || filter?(:home, status, into_account) + add_to_feed(:home, into_account, status) end trim(:home, into_account.id) @@ -68,22 +84,28 @@ class FeedManager timeline_key = key(:home, into_account.id) oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 - from_account.statuses.select('id').where('id > ?', oldest_home_score).reorder(nil).find_in_batches do |statuses| - redis.pipelined do - statuses.each do |status| - redis.zrem(timeline_key, status.id) - redis.zremrangebyscore(timeline_key, status.id, status.id) - end - end + from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status| + unpush(:home, into_account, status) end end def clear_from_timeline(account, target_account) timeline_key = key(:home, account.id) timeline_status_ids = redis.zrange(timeline_key, 0, -1) - target_status_ids = Status.where(id: timeline_status_ids, account: target_account).ids + target_statuses = Status.where(id: timeline_status_ids, account: target_account) + + target_statuses.each do |status| + unpush(:home, account, status) + end + end - redis.zrem(timeline_key, target_status_ids) if target_status_ids.present? + def populate_feed(account) + prepopulate_limit = FeedManager::MAX_ITEMS / 4 + statuses = Status.as_home_timeline(account).order(account_id: :desc).limit(prepopulate_limit) + statuses.reverse_each do |status| + next if filter_from_home?(status, account) + add_to_feed(:home, account, status) + end end private @@ -137,4 +159,58 @@ class FeedManager should_filter end + + # Adds a status to an account's feed, returning true if a status was + # added, and false if it was not added to the feed. Note that this is + # an internal helper: callers must call trim or push updates if + # either action is appropriate. + def add_to_feed(timeline_type, account, status) + timeline_key = key(timeline_type, account.id) + reblog_key = key(timeline_type, account.id, 'reblogs') + + if status.reblog? + # If the original status or a reblog of it is within + # REBLOG_FALLOFF statuses from the top, do not re-insert it into + # the feed + rank = redis.zrevrank(timeline_key, status.reblog_of_id) + return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF + + reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id) + return false unless reblog_rank.nil? + + redis.zadd(timeline_key, status.id, status.id) + redis.zadd(reblog_key, status.id, status.reblog_of_id) + else + redis.zadd(timeline_key, status.id, status.id) + end + + true + end + + # Removes an individual status from a feed, correctly handling cases + # with reblogs, and returning true if a status was removed. As with + # `add_to_feed`, this does not trigger push updates, so callers must + # do so if appropriate. + def remove_from_feed(timeline_type, account, status) + timeline_key = key(timeline_type, account.id) + reblog_key = key(timeline_type, account.id, 'reblogs') + + if status.reblog? + # 1. If the reblogging status is not in the feed, stop. + status_rank = redis.zrevrank(timeline_key, status.id) + return false if status_rank.nil? + + # 2. Remove the reblogged status from the `:reblogs` zset. + redis.zrem(reblog_key, status.reblog_of_id) + + # 3. Add the reblogged status to the feed using the reblogging + # status' ID as its score, and the reblogged status' ID as its + # value. + redis.zadd(timeline_key, status.id, status.reblog_of_id) + + # 4. Remove the reblogging status from the feed (as normal) + end + + redis.zrem(timeline_key, status.id) + end end diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 42cd72990..57f105da7 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -50,7 +50,7 @@ class Formatter end def simplified_format(account) - return reformat(account.note) unless account.local? + return reformat(account.note).html_safe unless account.local? # rubocop:disable Rails/OutputSafety html = encode_and_link_urls(account.note) html = simple_format(html, {}, sanitize: false) @@ -92,7 +92,7 @@ class Formatter def encode_custom_emojis(html, emojis) return html if emojis.empty? - emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url)] }.to_h + emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url(:static))] }.to_h i = -1 inside_tag = false diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index 039381397..8b27b124f 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true class OStatus::Activity::Base - def initialize(xml, account = nil) - @xml = xml + def initialize(xml, account = nil, options = {}) + @xml = xml @account = account + @options = options end def status? diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index 2687776f9..a1ab522e2 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -9,11 +9,6 @@ class OStatus::Activity::Creation < OStatus::Activity::Base return [nil, false] if @account.suspended? - if activitypub_uri? && [:public, :unlisted].include?(visibility_scope) - result = perform_via_activitypub - return result if result.first.present? - end - RedisLock.acquire(lock_options) do |lock| if lock.acquired? # Return early if status already exists in db @@ -39,7 +34,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base reblog: cached_reblog, text: content, spoiler_text: content_warning, - created_at: published, + created_at: @options[:override_timestamps] ? nil : published, reply: thread?, language: content_language, visibility: visibility_scope, @@ -66,10 +61,6 @@ class OStatus::Activity::Creation < OStatus::Activity::Base status end - def perform_via_activitypub - [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false] - end - def content @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS).content end diff --git a/app/lib/ostatus/activity/general.rb b/app/lib/ostatus/activity/general.rb index b3bef9861..8a6aabc33 100644 --- a/app/lib/ostatus/activity/general.rb +++ b/app/lib/ostatus/activity/general.rb @@ -2,7 +2,7 @@ class OStatus::Activity::General < OStatus::Activity::Base def specialize - special_class&.new(@xml, @account) + special_class&.new(@xml, @account, @options) end private diff --git a/app/lib/request.rb b/app/lib/request.rb index b083edaf7..30ea0e7ee 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -32,7 +32,7 @@ class Request def perform http_client.headers(headers).public_send(@verb, @url.to_s, @options) rescue => e - raise e.class, "#{e.message} on #{@url}" + raise e.class, "#{e.message} on #{@url}", e.backtrace[0] end def headers @@ -85,6 +85,6 @@ class Request end def http_client - HTTP.timeout(:per_operation, timeout).follow + HTTP.timeout(:per_operation, timeout).follow(max_hops: 2) end end diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 3b156b98c..3b7a856ee 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -15,17 +15,17 @@ class UserSettingsDecorator private def process_update - user.settings['notification_emails'] = merged_notification_emails - user.settings['interactions'] = merged_interactions - user.settings['default_privacy'] = default_privacy_preference - user.settings['default_sensitive'] = default_sensitive_preference - user.settings['unfollow_modal'] = unfollow_modal_preference - user.settings['boost_modal'] = boost_modal_preference - user.settings['delete_modal'] = delete_modal_preference - user.settings['auto_play_gif'] = auto_play_gif_preference - user.settings['system_font_ui'] = system_font_ui_preference - user.settings['noindex'] = noindex_preference - user.settings['theme'] = theme_preference + user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails') + user.settings['interactions'] = merged_interactions if change?('interactions') + user.settings['default_privacy'] = default_privacy_preference if change?('setting_default_privacy') + user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive') + user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal') + user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') + user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') + user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif') + user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') + user.settings['noindex'] = noindex_preference if change?('setting_noindex') + user.settings['theme'] = theme_preference if change?('setting_theme') end def merged_notification_emails @@ -83,4 +83,8 @@ class UserSettingsDecorator def coerce_values(params_hash) params_hash.transform_values { |x| x == '1' } end + + def change?(key) + !settings[key].nil? + end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 1517c027e..c475a9911 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -18,6 +18,7 @@ class UserMailer < Devise::Mailer def reset_password_instructions(user, token, _opts = {}) @resource = user @token = token + @instance = Rails.configuration.x.local_domain I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject') @@ -26,6 +27,7 @@ class UserMailer < Devise::Mailer def password_change(user, _opts = {}) @resource = user + @instance = Rails.configuration.x.local_domain I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject') diff --git a/app/models/account.rb b/app/models/account.rb index de7998db4..85684c259 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -92,6 +92,10 @@ class Account < ApplicationRecord has_many :reports has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id + # Moderation notes + has_many :account_moderation_notes, dependent: :destroy + has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy + scope :remote, -> { where.not(domain: nil) } scope :local, -> { where(domain: nil) } scope :without_followers, -> { where(followers_count: 0) } @@ -139,6 +143,15 @@ class Account < ApplicationRecord subscription_expires_at.present? end + def possibly_stale? + last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago + end + + def refresh! + return if local? + ResolveRemoteAccountService.new.call(acct) + end + def keypair @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end @@ -183,7 +196,8 @@ class Account < ApplicationRecord end def inboxes - reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + urls = reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + DeliveryFailureTracker.filter(urls) end def triadic_closures(account, limit: 5, offset: 0) diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb index bdd64c01a..fb695e473 100644 --- a/app/models/account_domain_block.rb +++ b/app/models/account_domain_block.rb @@ -3,11 +3,11 @@ # # Table name: account_domain_blocks # -# id :integer not null, primary key -# account_id :integer # domain :string # created_at :datetime not null # updated_at :datetime not null +# account_id :integer +# id :integer not null, primary key # class AccountDomainBlock < ApplicationRecord diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index 1a8cc5192..189872368 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -9,9 +9,11 @@ class AccountFilter def results scope = Account.alphabetic + params.each do |key, value| scope.merge!(scope_for(key, value)) if value.present? end + scope end diff --git a/app/models/account_moderation_note.rb b/app/models/account_moderation_note.rb new file mode 100644 index 000000000..3ac9b1ac1 --- /dev/null +++ b/app/models/account_moderation_note.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_moderation_notes +# +# id :integer not null, primary key +# content :text not null +# account_id :integer not null +# target_account_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class AccountModerationNote < ApplicationRecord + belongs_to :account + belongs_to :target_account, class_name: 'Account' + + scope :latest, -> { reorder('created_at DESC') } + + validates :content, presence: true, length: { maximum: 500 } +end diff --git a/app/models/block.rb b/app/models/block.rb index edb0d2d11..a913782ed 100644 --- a/app/models/block.rb +++ b/app/models/block.rb @@ -3,11 +3,11 @@ # # Table name: blocks # -# id :integer not null, primary key -# account_id :integer not null -# target_account_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# account_id :integer not null +# id :integer not null, primary key +# target_account_id :integer not null # class Block < ApplicationRecord diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb index 79299b995..8d2399adf 100644 --- a/app/models/conversation_mute.rb +++ b/app/models/conversation_mute.rb @@ -3,9 +3,9 @@ # # Table name: conversation_mutes # -# id :integer not null, primary key -# account_id :integer not null # conversation_id :integer not null +# account_id :integer not null +# id :integer not null, primary key # class ConversationMute < ApplicationRecord diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index e80c58155..65d9840d5 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -12,6 +12,9 @@ # image_updated_at :datetime # created_at :datetime not null # updated_at :datetime not null +# disabled :boolean default(FALSE), not null +# uri :string +# image_remote_url :string # class CustomEmoji < ApplicationRecord @@ -21,15 +24,25 @@ class CustomEmoji < ApplicationRecord :(#{SHORTCODE_RE_FRAGMENT}): (?=[^[:alnum:]:]|$)/x - has_attached_file :image + has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } } validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes } validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 } - scope :local, -> { where(domain: nil) } + scope :local, -> { where(domain: nil) } + scope :remote, -> { where.not(domain: nil) } + scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) } include Remotable + def local? + domain.nil? + end + + def object_type + :emoji + end + class << self def from_text(text, domain) return [] if text.blank? @@ -38,7 +51,7 @@ class CustomEmoji < ApplicationRecord return [] if shortcodes.empty? - where(shortcode: shortcodes, domain: domain) + where(shortcode: shortcodes, domain: domain, disabled: false) end end end diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb new file mode 100644 index 000000000..2d1394a59 --- /dev/null +++ b/app/models/custom_emoji_filter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CustomEmojiFilter + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = CustomEmoji.alphabetic + + params.each do |key, value| + scope.merge!(scope_for(key, value)) if value.present? + end + + scope + end + + private + + def scope_for(key, value) + case key.to_s + when 'local' + CustomEmoji.local + when 'remote' + CustomEmoji.remote + when 'by_domain' + CustomEmoji.where(domain: value) + else + raise "Unknown filter: #{key}" + end + end +end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index aea8919af..1268290bc 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -3,12 +3,12 @@ # # Table name: domain_blocks # -# id :integer not null, primary key # domain :string default(""), not null # created_at :datetime not null # updated_at :datetime not null # severity :integer default("silence") # reject_media :boolean default(FALSE), not null +# id :integer not null, primary key # class DomainBlock < ApplicationRecord diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb new file mode 100644 index 000000000..839038bea --- /dev/null +++ b/app/models/email_domain_block.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: email_domain_blocks +# +# id :integer not null, primary key +# domain :string not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class EmailDomainBlock < ApplicationRecord + def self.block?(email) + domain = email.gsub(/.+@([^.]+)/, '\1') + where(domain: domain).exists? + end +end diff --git a/app/models/favourite.rb b/app/models/favourite.rb index 53c79ccea..d28d5c05b 100644 --- a/app/models/favourite.rb +++ b/app/models/favourite.rb @@ -3,11 +3,11 @@ # # Table name: favourites # -# id :integer not null, primary key -# account_id :integer not null -# status_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# account_id :integer not null +# id :integer not null, primary key +# status_id :integer not null # class Favourite < ApplicationRecord diff --git a/app/models/feed.rb b/app/models/feed.rb index beb4a8de3..5f7b7877a 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -19,7 +19,7 @@ class Feed def from_redis(limit, max_id, since_id) max_id = '+inf' if max_id.blank? since_id = '-inf' if since_id.blank? - unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i) + unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i) Status.where(id: unhydrated).cache_ids end diff --git a/app/models/follow.rb b/app/models/follow.rb index 62f6fb670..667720a88 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -3,11 +3,11 @@ # # Table name: follows # -# id :integer not null, primary key -# account_id :integer not null -# target_account_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# account_id :integer not null +# id :integer not null, primary key +# target_account_id :integer not null # class Follow < ApplicationRecord diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 458c3a2cd..60036d903 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -3,11 +3,11 @@ # # Table name: follow_requests # -# id :integer not null, primary key -# account_id :integer not null -# target_account_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# account_id :integer not null +# id :integer not null, primary key +# target_account_id :integer not null # class FollowRequest < ApplicationRecord diff --git a/app/models/import.rb b/app/models/import.rb index 4656c3af6..8ae7e3a46 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -3,8 +3,6 @@ # # Table name: imports # -# id :integer not null, primary key -# account_id :integer not null # type :integer not null # approved :boolean default(FALSE), not null # created_at :datetime not null @@ -13,6 +11,8 @@ # data_content_type :string # data_file_size :integer # data_updated_at :datetime +# account_id :integer not null +# id :integer not null, primary key # class Import < ApplicationRecord diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 65ff893a8..f6c8879c5 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -16,6 +16,7 @@ # shortcode :string # type :integer default("image"), not null # file_meta :json +# description :text # require 'mime/types' @@ -75,6 +76,7 @@ class MediaAttachment < ApplicationRecord validates_attachment_size :file, less_than: 8.megabytes validates :account, presence: true + validates :description, length: { maximum: 420 }, if: :local? scope :attached, -> { where.not(status_id: nil) } scope :unattached, -> { where(status_id: nil) } @@ -95,6 +97,7 @@ class MediaAttachment < ApplicationRecord shortcode end + before_create :prepare_description, unless: :local? before_create :set_shortcode before_post_process :set_type_and_extension before_save :set_meta @@ -157,6 +160,10 @@ class MediaAttachment < ApplicationRecord end end + def prepare_description + self.description = description.strip[0...420] unless description.nil? + end + def set_type_and_extension self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : AUDIO_MIME_TYPES.include?(file_content_type) ? :audio : :image extension = AUDIO_MIME_TYPES.include?(file_content_type) ? '.mp4' : appropriate_extension diff --git a/app/models/mention.rb b/app/models/mention.rb index 7450b1b85..3700c781c 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -3,11 +3,11 @@ # # Table name: mentions # -# id :integer not null, primary key -# account_id :integer # status_id :integer # created_at :datetime not null # updated_at :datetime not null +# account_id :integer +# id :integer not null, primary key # class Mention < ApplicationRecord diff --git a/app/models/mute.rb b/app/models/mute.rb index 0d597a275..bcd3d247c 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -3,11 +3,11 @@ # # Table name: mutes # -# id :integer not null, primary key -# account_id :integer not null -# target_account_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# account_id :integer not null +# id :integer not null, primary key +# target_account_id :integer not null # hide_notifications :boolean default(TRUE), not null # diff --git a/app/models/report.rb b/app/models/report.rb index 479aa17bb..bffb42b48 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -3,15 +3,15 @@ # # Table name: reports # -# id :integer not null, primary key -# account_id :integer not null -# target_account_id :integer not null # status_ids :integer default([]), not null, is an Array # comment :text default(""), not null # action_taken :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null +# account_id :integer not null # action_taken_by_account_id :integer +# id :integer not null, primary key +# target_account_id :integer not null # class Report < ApplicationRecord diff --git a/app/models/setting.rb b/app/models/setting.rb index 340552581..a14f156a1 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -3,13 +3,13 @@ # # Table name: settings # -# id :integer not null, primary key # var :string not null # value :text # thing_type :string -# thing_id :integer # created_at :datetime # updated_at :datetime +# id :integer not null, primary key +# thing_id :integer # class Setting < RailsSettings::Base diff --git a/app/models/status.rb b/app/models/status.rb index e1697b8af..107ccface 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -136,6 +136,8 @@ class Status < ApplicationRecord after_create :store_uri, if: :local? + around_create Mastodon::Snowflake::Callbacks + before_validation :prepare_contents, if: :local? before_validation :set_reblog before_validation :set_visibility diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb index cff232916..720cd518c 100644 --- a/app/models/stream_entry.rb +++ b/app/models/stream_entry.rb @@ -1,16 +1,15 @@ # frozen_string_literal: true - # == Schema Information # # Table name: stream_entries # -# id :integer not null, primary key -# account_id :integer # activity_id :integer # activity_type :string # created_at :datetime not null # updated_at :datetime not null # hidden :boolean default(FALSE), not null +# account_id :integer +# id :integer not null, primary key # class StreamEntry < ApplicationRecord diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 14f1a140c..39860196b 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -3,16 +3,16 @@ # # Table name: subscriptions # -# id :integer not null, primary key # callback_url :string default(""), not null # secret :string # expires_at :datetime # confirmed :boolean default(FALSE), not null -# account_id :integer not null # created_at :datetime not null # updated_at :datetime not null # last_successful_delivery_at :datetime # domain :string +# account_id :integer not null +# id :integer not null, primary key # class Subscription < ApplicationRecord diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb index 04a049523..1b0bfb2b7 100644 --- a/app/models/web/setting.rb +++ b/app/models/web/setting.rb @@ -3,11 +3,11 @@ # # Table name: web_settings # -# id :integer not null, primary key -# user_id :integer # data :json # created_at :datetime not null # updated_at :datetime not null +# id :integer not null, primary key +# user_id :integer # class Web::Setting < ApplicationRecord diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index b252e008b..df399211c 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -3,10 +3,11 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer attributes :id, :type, :actor, :published, :to, :cc - has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer + has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :announce? + attribute :proper_uri, key: :object, if: :announce? def id - [ActivityPub::TagManager.instance.activity_uri_for(object)].join + ActivityPub::TagManager.instance.activity_uri_for(object) end def type @@ -29,6 +30,10 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer ActivityPub::TagManager.instance.cc(object) end + def proper_uri + ActivityPub::TagManager.instance.uri_for(object.proper) + end + def announce? object.reblog? end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index a11178f5b..896d67115 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -10,20 +10,6 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer - class ImageSerializer < ActiveModel::Serializer - include RoutingHelper - - attributes :type, :url - - def type - 'Image' - end - - def url - full_asset_url(object.url(:original)) - end - end - class EndpointsSerializer < ActiveModel::Serializer include RoutingHelper @@ -36,8 +22,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer has_one :endpoints, serializer: EndpointsSerializer - has_one :icon, serializer: ImageSerializer, if: :avatar_exists? - has_one :image, serializer: ImageSerializer, if: :header_exists? + has_one :icon, serializer: ActivityPub::ImageSerializer, if: :avatar_exists? + has_one :image, serializer: ActivityPub::ImageSerializer, if: :header_exists? def id account_url(object) diff --git a/app/serializers/activitypub/emoji_serializer.rb b/app/serializers/activitypub/emoji_serializer.rb new file mode 100644 index 000000000..7b06b1e5d --- /dev/null +++ b/app/serializers/activitypub/emoji_serializer.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ActivityPub::EmojiSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :id, :type, :name, :updated + + has_one :icon, serializer: ActivityPub::ImageSerializer + + def id + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'Emoji' + end + + def icon + object.image + end + + def updated + object.updated_at.iso8601 + end + + def name + ":#{object.shortcode}:" + end +end diff --git a/app/serializers/activitypub/image_serializer.rb b/app/serializers/activitypub/image_serializer.rb new file mode 100644 index 000000000..a015c6b1b --- /dev/null +++ b/app/serializers/activitypub/image_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ActivityPub::ImageSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :type, :media_type, :url + + def type + 'Image' + end + + def url + full_asset_url(object.url(:original)) + end + + def media_type + object.content_type + end +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index f94c3b9dc..24c39f3c9 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -89,12 +89,16 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper - attributes :type, :media_type, :url + attributes :type, :media_type, :url, :name def type 'Document' end + def name + object.description + end + def media_type object.file_content_type end @@ -138,21 +142,6 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer end end - class CustomEmojiSerializer < ActiveModel::Serializer - include RoutingHelper - - attributes :type, :href, :name - - def type - 'Emoji' - end - - def href - full_asset_url(object.image.url) - end - - def name - ":#{object.shortcode}:" - end + class CustomEmojiSerializer < ActivityPub::EmojiSerializer end end diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb new file mode 100644 index 000000000..95bcc21bb --- /dev/null +++ b/app/serializers/manifest_serializer.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ManifestSerializer < ActiveModel::Serializer + include RoutingHelper + include ActionView::Helpers::TextHelper + + attributes :name, :short_name, :description, + :icons, :theme_color, :background_color, + :display, :start_url, :scope + + def name + object.site_title + end + + def short_name + object.site_title + end + + def description + strip_tags(object.site_description.presence || I18n.t('about.about_mastodon_html')) + end + + def icons + [ + { + src: '/android-chrome-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + ] + end + + def theme_color + '#282c37' + end + + def background_color + '#191b22' + end + + def display + 'standalone' + end + + def start_url + '/web/timelines/home' + end + + def scope + root_url + end +end diff --git a/app/serializers/rest/application_serializer.rb b/app/serializers/rest/application_serializer.rb index a8945f66e..a9316cd4b 100644 --- a/app/serializers/rest/application_serializer.rb +++ b/app/serializers/rest/application_serializer.rb @@ -15,4 +15,8 @@ class REST::ApplicationSerializer < ActiveModel::Serializer def client_secret object.secret end + + def website + object.website.presence + end end diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb index b744dd4ec..b958e6a5d 100644 --- a/app/serializers/rest/custom_emoji_serializer.rb +++ b/app/serializers/rest/custom_emoji_serializer.rb @@ -3,9 +3,13 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer include RoutingHelper - attributes :shortcode, :url + attributes :shortcode, :url, :static_url def url full_asset_url(object.image.url) end + + def static_url + full_asset_url(object.image.url(:static)) + end end diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb index f6e7c79d1..51011788b 100644 --- a/app/serializers/rest/media_attachment_serializer.rb +++ b/app/serializers/rest/media_attachment_serializer.rb @@ -4,7 +4,8 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper attributes :id, :type, :url, :preview_url, - :remote_url, :text_url, :meta + :remote_url, :text_url, :meta, + :description def id object.id.to_s @@ -18,6 +19,10 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer end end + def remote_url + object.remote_url.presence + end + def preview_url if object.needs_redownload? media_proxy_url(object.id, :small) diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 3eeca585e..d6ba625a9 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -5,14 +5,18 @@ class ActivityPub::FetchRemoteAccountService < BaseService # Should be called when uri has already been checked for locality # Does a WebFinger roundtrip on each call - def call(uri, prefetched_json = nil) - @json = body_to_json(prefetched_json) || fetch_resource(uri) + def call(uri, id: true, prefetched_body: nil) + @json = if prefetched_body.nil? + fetch_resource(uri, id) + else + body_to_json(prefetched_body) + end return unless supported_context? && expected_type? @uri = @json['id'] @username = @json['preferredUsername'] - @domain = Addressable::URI.parse(uri).normalized_host + @domain = Addressable::URI.parse(@uri).normalized_host return unless verified_webfinger? @@ -27,17 +31,15 @@ class ActivityPub::FetchRemoteAccountService < BaseService webfinger = Goldfinger.finger("acct:#{@username}@#{@domain}") confirmed_username, confirmed_domain = split_acct(webfinger.subject) - return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}") - confirmed_username, confirmed_domain = split_acct(webfinger.subject) + @username, @domain = split_acct(webfinger.subject) self_reference = webfinger.link('self') + return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? return false if self_reference&.href != @uri - @username = confirmed_username - @domain = confirmed_domain - true rescue Goldfinger::Error false diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index ebd64071e..ce1048fee 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -4,13 +4,26 @@ class ActivityPub::FetchRemoteKeyService < BaseService include JsonLdHelper # Returns account that owns the key - def call(uri, prefetched_json = nil) - @json = body_to_json(prefetched_json) || fetch_resource(uri) + def call(uri, id: true, prefetched_body: nil) + if prefetched_body.nil? + if id + @json = fetch_resource_without_id_validation(uri) + if person? + @json = fetch_resource(@json['id'], true) + elsif uri != @json['id'] + return + end + else + @json = fetch_resource(uri, id) + end + else + @json = body_to_json(prefetched_body) + end return unless supported_context?(@json) && expected_type? - return find_account(uri, @json) if person? + return find_account(@json['id'], @json) if person? - @owner = fetch_resource(owner_uri) + @owner = fetch_resource(owner_uri, true) return unless supported_context?(@owner) && confirmed_owner? @@ -19,9 +32,9 @@ class ActivityPub::FetchRemoteKeyService < BaseService private - def find_account(uri, prefetched_json) + def find_account(uri, prefetched_body) account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) - account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json) + account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body) account end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index a95931afe..e2a89a87c 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -4,36 +4,33 @@ class ActivityPub::FetchRemoteStatusService < BaseService include JsonLdHelper # Should be called when uri has already been checked for locality - def call(uri, prefetched_json = nil) - @json = body_to_json(prefetched_json) || fetch_resource(uri) + def call(uri, id: true, prefetched_body: nil) + @json = if prefetched_body.nil? + fetch_resource(uri, id) + else + body_to_json(prefetched_body) + end - return unless supported_context? + return unless supported_context? && expected_type? - activity = activity_json - actor_id = value_or_id(activity['actor']) - - return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id) + return if actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id) actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account) - actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil? + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? return if actor.suspended? - ActivityPub::Activity.factory(activity, actor).perform + ActivityPub::Activity.factory(activity_json, actor).perform end private def activity_json - if %w(Note Article).include? @json['type'] - { - 'type' => 'Create', - 'actor' => first_of_value(@json['attributedTo']), - 'object' => @json, - } - else - @json - end + { 'type' => 'Create', 'actor' => actor_id, 'object' => @json } + end + + def actor_id + first_of_value(@json['attributedTo']) end def trustworthy_attribution?(uri, attributed_to) @@ -44,7 +41,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService super(@json) end - def expected_type?(json) - %w(Create Announce).include? json['type'] + def expected_type? + %w(Note Article).include? @json['type'] end end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 811209537..f93baf4b5 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -90,7 +90,7 @@ class ActivityPub::ProcessAccountService < BaseService return if value.nil? return value['url'] if value.is_a?(Hash) - image = fetch_resource(value) + image = fetch_resource_without_id_validation(value) image['url'] if image end @@ -100,7 +100,7 @@ class ActivityPub::ProcessAccountService < BaseService return if value.nil? return value['publicKeyPem'] if value.is_a?(Hash) - key = fetch_resource(value) + key = fetch_resource_without_id_validation(value) key['publicKeyPem'] if key end @@ -130,7 +130,7 @@ class ActivityPub::ProcessAccountService < BaseService return if @json[type].blank? return @collections[type] if @collections.key?(type) - collection = fetch_resource(@json[type]) + collection = fetch_resource_without_id_validation(@json[type]) @collections[type] = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil rescue HTTP::Error, OpenSSL::SSL::SSLError diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index 59cb65c65..db4d1b4bc 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -3,9 +3,10 @@ class ActivityPub::ProcessCollectionService < BaseService include JsonLdHelper - def call(body, account) + def call(body, account, options = {}) @account = account @json = Oj.load(body, mode: :strict) + @options = options return unless supported_context? return if different_actor? && verify_account!.nil? @@ -38,7 +39,7 @@ class ActivityPub::ProcessCollectionService < BaseService end def process_item(item) - activity = ActivityPub::Activity.factory(item, @account) + activity = ActivityPub::Activity.factory(item, @account, @options) activity&.perform end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 2fd623922..5d83771c9 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -29,7 +29,7 @@ class BatchedRemoveStatusService < BaseService statuses.group_by(&:account_id).each do |_, account_statuses| account = account_statuses.first.account - unpush_from_home_timelines(account_statuses) + unpush_from_home_timelines(account, account_statuses) if account.local? batch_stream_entries(account, account_statuses) @@ -72,14 +72,15 @@ class BatchedRemoveStatusService < BaseService end end - def unpush_from_home_timelines(statuses) - account = statuses.first.account - recipients = account.followers.local.pluck(:id) + def unpush_from_home_timelines(account, statuses) + recipients = account.followers.local.to_a - recipients << account.id if account.local? + recipients << account if account.local? - recipients.each do |follower_id| - unpush(follower_id, statuses) + recipients.each do |follower| + statuses.each do |status| + FeedManager.instance.unpush(:home, follower, status) + end end end @@ -109,28 +110,6 @@ class BatchedRemoveStatusService < BaseService end end - def unpush(follower_id, statuses) - key = FeedManager.instance.key(:home, follower_id) - - originals = statuses.reject(&:reblog?) - reblogs = statuses.select(&:reblog?) - - # Quickly remove all originals - redis.pipelined do - originals.each do |status| - redis.zremrangebyscore(key, status.id, status.id) - redis.publish("timeline:#{follower_id}", @json_payloads[status.id]) - end - end - - # For reblogs, re-add original status to feed, unless the reblog - # was not in the feed in the first place - reblogs.each do |status| - redis.zadd(key, status.reblog_of_id, status.reblog_of_id) unless redis.zscore(key, status.reblog_of_id).nil? - redis.publish("timeline:#{follower_id}", @json_payloads[status.id]) - end - end - def redis Redis.current end diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 9c5777b5d..1c47a22da 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -41,10 +41,11 @@ class FetchAtomService < BaseService return nil if @response.code != 200 if @response.mime_type == 'application/atom+xml' - [@url, @response.to_s, :ostatus] + [@url, { prefetched_body: @response.to_s }, :ostatus] elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) - if supported_activity?(@response.to_s) - [@url, @response.to_s, :activitypub] + json = body_to_json(@response.to_s) + if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? + [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub] else @unsupported_activity = true nil @@ -79,10 +80,4 @@ class FetchAtomService < BaseService result end - - def supported_activity?(body) - json = body_to_json(body) - return false unless supported_context?(json) - json['type'] == 'Person' ? json['inbox'].present? : true - end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 4acbfae7a..cf3d78683 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -27,7 +27,8 @@ class FetchLinkCardService < BaseService end attach_card if @card&.persisted? - rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError + rescue HTTP::Error, Addressable::URI::InvalidURIError => e + Rails.logger.debug "Error fetching link #{@url}: #{e}" nil end diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index bd98e70d1..a0f031a44 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -5,24 +5,24 @@ class FetchRemoteAccountService < BaseService def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - resource_url, body, protocol = FetchAtomService.new.call(url) + resource_url, resource_options, protocol = FetchAtomService.new.call(url) else - resource_url = url - body = prefetched_body + resource_url = url + resource_options = { prefetched_body: prefetched_body } end case protocol when :ostatus - process_atom(resource_url, body) + process_atom(resource_url, **resource_options) when :activitypub - ActivityPub::FetchRemoteAccountService.new.call(resource_url, body) + ActivityPub::FetchRemoteAccountService.new.call(resource_url, **resource_options) end end private - def process_atom(url, body) - xml = Nokogiri::XML(body) + def process_atom(url, prefetched_body:) + xml = Nokogiri::XML(prefetched_body) xml.encoding = 'utf-8' account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: OStatus::TagManager::XMLNS), false) diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb index 341664272..6d40796f2 100644 --- a/app/services/fetch_remote_resource_service.rb +++ b/app/services/fetch_remote_resource_service.rb @@ -33,7 +33,7 @@ class FetchRemoteResourceService < BaseService end def body - fetched_atom_feed.second + fetched_atom_feed.second[:prefetched_body] end def protocol diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index 1b90854c4..cacf6ba51 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -5,26 +5,26 @@ class FetchRemoteStatusService < BaseService def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - resource_url, body, protocol = FetchAtomService.new.call(url) + resource_url, resource_options, protocol = FetchAtomService.new.call(url) else - resource_url = url - body = prefetched_body + resource_url = url + resource_options = { prefetched_body: prefetched_body } end case protocol when :ostatus - process_atom(resource_url, body) + process_atom(resource_url, **resource_options) when :activitypub - ActivityPub::FetchRemoteStatusService.new.call(resource_url, body) + ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options) end end private - def process_atom(url, body) + def process_atom(url, prefetched_body:) Rails.logger.debug "Processing Atom for remote status at #{url}" - xml = Nokogiri::XML(body) + xml = Nokogiri::XML(prefetched_body) xml.encoding = 'utf-8' account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) @@ -32,7 +32,7 @@ class FetchRemoteStatusService < BaseService return nil unless !account.nil? && confirmed_domain?(domain, account) - statuses = ProcessFeedService.new.call(body, account) + statuses = ProcessFeedService.new.call(prefetched_body, account) statuses.first rescue Nokogiri::XML::XPath::SyntaxError Rails.logger.debug 'Invalid XML or missing namespace' diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 56cbebd5d..a9a02937e 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -3,7 +3,8 @@ class MuteService < BaseService def call(account, target_account, notifications: nil) return if account.id == target_account.id - FeedManager.instance.clear_from_timeline(account, target_account) account.mute!(target_account, notifications: notifications) + BlockWorker.perform_async(account.id, target_account.id) + mute end end diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 85635a008..36aabaa00 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -1,43 +1,7 @@ # frozen_string_literal: true class PrecomputeFeedService < BaseService - LIMIT = FeedManager::MAX_ITEMS / 4 - def call(account) - @account = account - populate_feed - end - - private - - attr_reader :account - - def populate_feed - pairs = statuses.reverse_each.lazy.reject(&method(:status_filtered?)).map(&method(:process_status)).to_a - - redis.pipelined do - redis.zadd(account_home_key, pairs) if pairs.any? - redis.del("account:#{@account.id}:regeneration") - end - end - - def process_status(status) - [status.id, status.reblog? ? status.reblog_of_id : status.id] - end - - def status_filtered?(status) - FeedManager.instance.filter?(:home, status, account.id) - end - - def account_home_key - FeedManager.instance.key(:home, account.id) - end - - def statuses - Status.as_home_timeline(account).order(account_id: :desc).limit(LIMIT) - end - - def redis - Redis.current + FeedManager.instance.populate_feed(account) end end diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 2a5f1e2bc..60eff135e 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class ProcessFeedService < BaseService - def call(body, account) + def call(body, account, options = {}) + @options = options + xml = Nokogiri::XML(body) xml.encoding = 'utf-8' @@ -20,7 +22,7 @@ class ProcessFeedService < BaseService end def process_entry(xml, account) - activity = OStatus::Activity::General.new(xml, account) + activity = OStatus::Activity::General.new(xml, account, @options) activity.specialize&.perform if activity.status? rescue ActiveRecord::RecordInvalid => e Rails.logger.debug "Nothing was saved for #{activity.id} because: #{e}" diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 14f24908c..96d9208cc 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -102,13 +102,7 @@ class RemoveStatusService < BaseService end def unpush(type, receiver, status) - if status.reblog? && !redis.zscore(FeedManager.instance.key(type, receiver.id), status.reblog_of_id).nil? - redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id) - else - redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id) - end - - Redis.current.publish("timeline:#{receiver.id}", @payload) + FeedManager.instance.unpush(type, receiver, status) end def remove_from_hashtags diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index 57c80fc82..3d0a36f6c 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -74,7 +74,7 @@ class ResolveRemoteAccountService < BaseService end def webfinger_update_due? - @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago + @account.nil? || @account.possibly_stale? end def activitypub_ready? @@ -189,7 +189,7 @@ class ResolveRemoteAccountService < BaseService def actor_json return @actor_json if defined?(@actor_json) - json = fetch_resource(actor_url) + json = fetch_resource(actor_url, false) @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil end diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb index c11813abc..af205c9c9 100644 --- a/app/services/send_interaction_service.rb +++ b/app/services/send_interaction_service.rb @@ -12,7 +12,7 @@ class SendInteractionService < BaseService return if !target_account.ostatus? || block_notification? - delivery = build_request.perform + delivery = build_request.perform.flush raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300 end diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb index bfa7ff8c8..2d8af0203 100644 --- a/app/services/subscribe_service.rb +++ b/app/services/subscribe_service.rb @@ -6,7 +6,7 @@ class SubscribeService < BaseService @account = account @account.secret = SecureRandom.hex - @response = build_request.perform + @response = build_request.perform.flush if response_failed_permanently? # We're not allowed to subscribe. Fail and move on. diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb index b99046712..d84a5a530 100644 --- a/app/services/unsubscribe_service.rb +++ b/app/services/unsubscribe_service.rb @@ -7,7 +7,7 @@ class UnsubscribeService < BaseService @account = account begin - @response = build_request.perform + @response = build_request.perform.flush Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success? rescue HTTP::Error, OpenSSL::SSL::SSLError => e diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb index 0ba79694b..3f203f49a 100644 --- a/app/validators/blacklisted_email_validator.rb +++ b/app/validators/blacklisted_email_validator.rb @@ -12,6 +12,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator end def on_blacklist?(value) + return true if EmailDomainBlock.block?(value) return false if Rails.configuration.x.email_domains_blacklist.blank? domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.') diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 6e4d0cdd1..7ffa5ecc3 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -3,7 +3,7 @@ - content_for :header_tags do = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' - = render partial: 'og' + = render partial: 'shared/og' .landing-page .header-wrapper.compact diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index c0fa944ae..385b0b1dc 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -4,7 +4,7 @@ - content_for :header_tags do %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous' - = render partial: 'og' + = render partial: 'shared/og' .landing-page .header-wrapper @@ -69,7 +69,7 @@ .about-mastodon %h3= t 'about.what_is_mastodon' %p= t 'about.about_mastodon_html' - %a.button.button-secondary{ href: 'https://joinmastodon.org/' }= t 'about.learn_more' + = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary' = render 'features' .footer-links .container diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml new file mode 100644 index 000000000..4651630e9 --- /dev/null +++ b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml @@ -0,0 +1,10 @@ +%tr + %td + = simple_format(h(account_moderation_note.content)) + %td + = account_moderation_note.account.acct + %td + %time.formatted{ datetime: account_moderation_note.created_at.iso8601, title: l(account_moderation_note.created_at) } + = l account_moderation_note.created_at + %td + = link_to t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 3775b6721..1f5c8fcf5 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -129,3 +129,25 @@ %tr %th= t('admin.accounts.followers_url') %td= link_to @account.followers_url, @account.followers_url + +%hr +%h3= t('admin.accounts.moderation_notes') + += simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f| + = render 'shared/error_messages', object: @account_moderation_note + + = f.input :content + = f.hidden_field :target_account_id + + .actions + = f.button :button, t('admin.account_moderation_notes.create'), type: :submit + +.table-wrapper + %table.table + %thead + %tr + %th + %th= t('admin.account_moderation_notes.account') + %th= t('admin.account_moderation_notes.created_at') + %tbody + = render @moderation_notes diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index ff1aa9925..53263c43f 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -4,4 +4,17 @@ %td %samp= ":#{custom_emoji.shortcode}:" %td + - if custom_emoji.local? + = t('admin.accounts.location.local') + - else + = custom_emoji.domain + %td + - unless custom_emoji.local? + = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji), method: :post + %td + - if custom_emoji.disabled? + = table_link_to 'power-off', t('admin.custom_emojis.enable'), enable_admin_custom_emoji_path(custom_emoji), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } + - else + = table_link_to 'power-off', t('admin.custom_emojis.disable'), disable_admin_custom_emoji_path(custom_emoji), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } + %td = table_link_to 'times', t('admin.custom_emojis.delete'), admin_custom_emoji_path(custom_emoji), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml index d5f32e84b..20ffb8529 100644 --- a/app/views/admin/custom_emojis/index.html.haml +++ b/app/views/admin/custom_emojis/index.html.haml @@ -1,14 +1,34 @@ - content_for :page_title do = t('admin.custom_emojis.title') +.filters + .filter-subset + %strong= t('admin.accounts.location.title') + %ul + %li= filter_link_to t('admin.accounts.location.all'), local: nil, remote: nil + %li + - if selected? local: '1', remote: nil + = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil}, {local: '1', remote: nil} + - else + = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil + %li + - if selected? remote: '1', local: nil + = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil}, {remote: '1', local: nil} + - else + = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil + .table-wrapper %table.table %thead %tr %th= t('admin.custom_emojis.emoji') %th= t('admin.custom_emojis.shortcode') + %th= t('admin.accounts.domain') + %th + %th %th %tbody = render @custom_emojis += paginate @custom_emojis = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' diff --git a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml new file mode 100644 index 000000000..61cff9395 --- /dev/null +++ b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml @@ -0,0 +1,5 @@ +%tr + %td.domain + %samp= email_domain_block.domain + %td + = table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete diff --git a/app/views/admin/email_domain_blocks/index.html.haml b/app/views/admin/email_domain_blocks/index.html.haml new file mode 100644 index 000000000..7bb204e52 --- /dev/null +++ b/app/views/admin/email_domain_blocks/index.html.haml @@ -0,0 +1,14 @@ +- content_for :page_title do + = t('admin.email_domain_blocks.title') + +.table-wrapper + %table.table + %thead + %tr + %th= t('admin.email_domain_blocks.domain') + %th + %tbody + = render @email_domain_blocks + += paginate @email_domain_blocks += link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button' diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml new file mode 100644 index 000000000..bcae867d9 --- /dev/null +++ b/app/views/admin/email_domain_blocks/new.html.haml @@ -0,0 +1,10 @@ +- content_for :page_title do + = t('.title') + += simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f| + = render 'shared/error_messages', object: @email_domain_block + + = f.input :domain, placeholder: t('admin.email_domain_blocks.domain') + + .actions + = f.button :button, t('.create'), type: :submit diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 807020310..f71675df0 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -1,6 +1,9 @@ - content_for :page_title do = t('auth.register') +- content_for :header_tags do + = render partial: 'shared/og' + = simple_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| = render 'shared/error_messages', object: resource diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml index e589377bf..a52b0053b 100644 --- a/app/views/auth/sessions/new.html.haml +++ b/app/views/auth/sessions/new.html.haml @@ -1,6 +1,9 @@ - content_for :page_title do = t('auth.login') +- content_for :header_tags do + = render partial: 'shared/og' + = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml index 8b260c619..d0eae4434 100644 --- a/app/views/layouts/error.html.haml +++ b/app/views/layouts/error.html.haml @@ -3,12 +3,12 @@ %head %meta{ content: 'text/html; charset=UTF-8', 'http-equiv' => 'Content-Type' }/ %meta{ charset: 'utf-8' }/ - %title= safe_join([yield(:page_title), title], ' - ') + %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ') %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/ = stylesheet_pack_tag 'common', media: 'all' = stylesheet_pack_tag 'application', integrity: true, media: 'all' %body.error .dialog - %img{ alt: title, src: '/oops.gif' }/ + %img{ alt: Setting.default_settings['site_title'], src: '/oops.gif' }/ %div %h1= yield :content diff --git a/app/views/manifests/show.json.rabl b/app/views/manifests/show.json.rabl deleted file mode 100644 index ee0a70324..000000000 --- a/app/views/manifests/show.json.rabl +++ /dev/null @@ -1,11 +0,0 @@ -object false - -node(:name) { Setting.site_title } -node(:short_name) { Setting.site_title } -node(:description) { strip_tags(Setting.site_description.presence || I18n.t('about.about_mastodon_html')) } -node(:icons) { [{ src: '/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' }] } -node(:theme_color) { '#282c37' } -node(:background_color) { '#d9e1e8' } -node(:display) { 'standalone' } -node(:start_url) { '/web/timelines/home' } -node(:scope) { root_url } diff --git a/app/views/settings/notifications/show.html.haml b/app/views/settings/notifications/show.html.haml new file mode 100644 index 000000000..80cd615c7 --- /dev/null +++ b/app/views/settings/notifications/show.html.haml @@ -0,0 +1,25 @@ +- content_for :page_title do + = t('settings.notifications') + += simple_form_for current_user, url: settings_notifications_path, html: { method: :put } do |f| + = render 'shared/error_messages', object: current_user + + .fields-group + = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| + = ff.input :follow, as: :boolean, wrapper: :with_label + = ff.input :follow_request, as: :boolean, wrapper: :with_label + = ff.input :reblog, as: :boolean, wrapper: :with_label + = ff.input :favourite, as: :boolean, wrapper: :with_label + = ff.input :mention, as: :boolean, wrapper: :with_label + + .fields-group + = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| + = ff.input :digest, as: :boolean, wrapper: :with_label + + .fields-group + = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| + = ff.input :must_be_follower, as: :boolean, wrapper: :with_label + = ff.input :must_be_following, as: :boolean, wrapper: :with_label + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 5efd538e4..7475e3fd2 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -4,48 +4,31 @@ = simple_form_for current_user, url: settings_preferences_path, html: { method: :put } do |f| = render 'shared/error_messages', object: current_user + %h4= t 'preferences.languages' + .fields-group - = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| safe_join([I18n.t("themes.#{theme}", default: theme)])}, wrapper: :with_label, include_blank: false - - = f.input :locale, - collection: I18n.available_locales, - wrapper: :with_label, - include_blank: false, - label_method: lambda { |locale| human_locale(locale) }, - selected: I18n.locale - - = f.input :filtered_languages, - collection: filterable_languages, - wrapper: :with_block_label, - include_blank: false, - label_method: lambda { |locale| human_locale(locale) }, - required: false, - as: :check_boxes, - collection_wrapper_tag: 'ul', - item_wrapper_tag: 'li' + = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale - = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + = f.input :filtered_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label + %h4= t 'preferences.publishing' .fields-group - = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| - = ff.input :follow, as: :boolean, wrapper: :with_label - = ff.input :follow_request, as: :boolean, wrapper: :with_label - = ff.input :reblog, as: :boolean, wrapper: :with_label - = ff.input :favourite, as: :boolean, wrapper: :with_label - = ff.input :mention, as: :boolean, wrapper: :with_label - = ff.input :digest, as: :boolean, wrapper: :with_label + = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - .fields-group - = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| - = ff.input :must_be_follower, as: :boolean, wrapper: :with_label - = ff.input :must_be_following, as: :boolean, wrapper: :with_label + = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label + + %h4= t 'preferences.other' .fields-group = f.input :setting_noindex, as: :boolean, wrapper: :with_label + %h4= t 'preferences.web' + .fields-group + - if Themes.instance.names.size > 1 + = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false + = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label diff --git a/app/views/about/_og.html.haml b/app/views/shared/_og.html.haml index dbd476915..dbd476915 100644 --- a/app/views/about/_og.html.haml +++ b/app/views/shared/_og.html.haml diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/stream_entries/_og_image.html.haml index b5058583b..1056c1744 100644 --- a/app/views/stream_entries/_og_image.html.haml +++ b/app/views/stream_entries/_og_image.html.haml @@ -17,7 +17,9 @@ - unless media.file.meta.nil? = opengraph 'og:video:width', media.file.meta['small']['width'] = opengraph 'og:video:height', media.file.meta['small']['height'] + = opengraph 'twitter:card', 'summary_large_image' - else = opengraph 'og:image', full_asset_url(account.avatar.url(:original)) = opengraph 'og:image:width', '120' = opengraph 'og:image:height','120' + = opengraph 'twitter:card', 'summary' diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index 1bb8a32b2..428069931 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -14,8 +14,6 @@ = render 'stream_entries/og_description', activity: @stream_entry.activity = render 'stream_entries/og_image', activity: @stream_entry.activity, account: @account - = opengraph 'twitter:card', 'summary_large_image' - - if show_landing_strip? = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account } diff --git a/app/views/tags/_og.html.haml b/app/views/tags/_og.html.haml new file mode 100644 index 000000000..853a499ae --- /dev/null +++ b/app/views/tags/_og.html.haml @@ -0,0 +1,6 @@ += opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname) += opengraph 'og:url', tag_url(@tag) += opengraph 'og:type', 'website' += opengraph 'og:title', "##{@tag.name}" += opengraph 'og:description', t('about.about_hashtag_html', hashtag: @tag.name) += opengraph 'twitter:card', 'summary' diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index 8cd2f1825..6266d3c0c 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -1,19 +1,38 @@ - content_for :page_title do = "##{@tag.name}" -.compact-header - %h1< - = link_to site_title, root_path - %br - %small ##{@tag.name} +- content_for :header_tags do + %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) + = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous' + = render 'og' -- if @statuses.empty? - .accounts-grid - = render partial: 'accounts/nothing_here' -- else - .activity-stream.h-feed - = render partial: 'stream_entries/status', collection: @statuses, as: :status +.landing-page.tag-page + .stripe + .features + .container + #mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } } -- if @statuses.size == 20 - .pagination - = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next', rel: 'next' + .about-mastodon + .brand + = link_to root_url do + = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' + + %p= t 'about.about_hashtag_html', hashtag: @tag.name + + .cta + = link_to t('auth.login'), new_user_session_path, class: 'button button-secondary' + = link_to t('about.learn_more'), root_url, class: 'button button-alternative' + + .features-list + .features-list__row + .text + %h6= t 'about.features.not_a_product_title' + = t 'about.features.not_a_product_body' + .visual + = fa_icon 'fw users' + .features-list__row + .text + %h6= t 'about.features.humane_approach_title' + = t 'about.features.humane_approach_body' + .visual + = fa_icon 'fw leaf' diff --git a/app/views/user_mailer/confirmation_instructions.en.html.erb b/app/views/user_mailer/confirmation_instructions.en.html.erb index f28a38be2..cd0d70377 100644 --- a/app/views/user_mailer/confirmation_instructions.en.html.erb +++ b/app/views/user_mailer/confirmation_instructions.en.html.erb @@ -3,10 +3,13 @@ <p>You just created an account on <%= @instance %>.</p> <p>To confirm your inscription, please click on the following link : <br> -<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %> +<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p> + +<p>If the above link did not work, copy and paste this URL into your address bar: <br> +<span><%= confirmation_url(@resource, confirmation_token: @token) %></span> <p>Please also check out our <%= link_to 'terms and conditions', terms_url %>.</p> <p>Sincerely,<p> -<p>The <%= @instance %> team</p> \ No newline at end of file +<p>The <%= @instance %> team</p> diff --git a/app/views/user_mailer/confirmation_instructions.ko.html.erb b/app/views/user_mailer/confirmation_instructions.ko.html.erb new file mode 100644 index 000000000..a749cd97b --- /dev/null +++ b/app/views/user_mailer/confirmation_instructions.ko.html.erb @@ -0,0 +1,13 @@ +<p>안녕하세요 <%= @resource.email %> 님!</p> + +<p><%= @instance %>에 새로 계정을 만들었습니다.</p> + +<p>아래 링크를 눌러 회원가입을 완료 하세요:<br> +<%= link_to '계정 활성화', confirmation_url(@resource, confirmation_token: @token) %></p> + +<p>만약 위의 링크가 작동하지 않는다면 아래 URL을 복사하여 주소창에 붙여넣으세요</p> +<span><%= confirmation_url(@resource, confirmation_token: @token) %></span> + +<p> <%= link_to '약관', terms_url %>도 확인 바랍니다.</p> + +<p><%= @instance %> 드림</p> diff --git a/app/views/user_mailer/confirmation_instructions.ko.text.erb b/app/views/user_mailer/confirmation_instructions.ko.text.erb new file mode 100644 index 000000000..c46400f07 --- /dev/null +++ b/app/views/user_mailer/confirmation_instructions.ko.text.erb @@ -0,0 +1,10 @@ +안녕하세요 <%= @resource.email %> 님! + +<%= @instance %>에 새로 계정을 만들었습니다. + +아래 링크를 눌러 회원가입을 완료 하세요. +<%= confirmation_url(@resource, confirmation_token: @token) %> + +약관도 확인 바랍니다. <%= terms_url %> + +<%= @instance %> 드림 diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb index a4e829343..7b1e06a70 100644 --- a/app/workers/activitypub/delivery_worker.rb +++ b/app/workers/activitypub/delivery_worker.rb @@ -3,7 +3,7 @@ class ActivityPub::DeliveryWorker include Sidekiq::Worker - sidekiq_options queue: 'push', retry: 5, dead: false + sidekiq_options queue: 'push', retry: 8, dead: false HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze @@ -15,7 +15,10 @@ class ActivityPub::DeliveryWorker perform_request raise Mastodon::UnexpectedResponseError, @response unless response_successful? + + failure_tracker.track_success! rescue => e + failure_tracker.track_failure! raise e.class, "Delivery failed for #{inbox_url}: #{e.message}", e.backtrace[0] end @@ -28,10 +31,14 @@ class ActivityPub::DeliveryWorker end def perform_request - @response = build_request.perform + @response = build_request.perform.flush end def response_successful? @response.code > 199 && @response.code < 300 end + + def failure_tracker + @failure_tracker ||= DeliveryFailureTracker.new(@inbox_url) + end end diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb index bb9adf64b..0e2e0eddd 100644 --- a/app/workers/activitypub/processing_worker.rb +++ b/app/workers/activitypub/processing_worker.rb @@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker sidekiq_options backtrace: true def perform(account_id, body) - ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id)) + ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true) end end diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb new file mode 100644 index 000000000..ed4c962c1 --- /dev/null +++ b/app/workers/import/relationship_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Import::RelationshipWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 8, dead: false + + def perform(account_id, target_account_uri, relationship) + from_account = Account.find(account_id) + target_account = ResolveRemoteAccountService.new.call(target_account_uri) + + return if target_account.nil? + + case relationship + when 'follow' + FollowService.new.call(from_account, target_account.acct) + when 'block' + BlockService.new.call(from_account, target_account) + when 'mute' + MuteService.new.call(from_account, target_account) + end + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb index 27cc6b365..d7c126f75 100644 --- a/app/workers/import_worker.rb +++ b/app/workers/import_worker.rb @@ -12,13 +12,8 @@ class ImportWorker def perform(import_id) @import = Import.find(import_id) - case @import.type - when 'blocking' - process_blocks - when 'following' - process_follows - when 'muting' - process_mutes + Import::RelationshipWorker.push_bulk(import_rows) do |row| + [@import.account_id, row.first, relationship_type] end @import.destroy @@ -26,49 +21,22 @@ class ImportWorker private - def from_account - @import.account - end - def import_contents Paperclip.io_adapters.for(@import.data).read end - def import_rows - CSV.new(import_contents).reject(&:blank?) - end - - def process_mutes - import_rows.each do |row| - begin - target_account = ResolveRemoteAccountService.new.call(row.first) - next if target_account.nil? - MuteService.new.call(from_account, target_account) - rescue Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError - next - end - end - end - - def process_blocks - import_rows.each do |row| - begin - target_account = ResolveRemoteAccountService.new.call(row.first) - next if target_account.nil? - BlockService.new.call(from_account, target_account) - rescue Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError - next - end + def relationship_type + case @import.type + when 'following' + 'follow' + when 'blocking' + 'block' + when 'muting' + 'mute' end end - def process_follows - import_rows.each do |row| - begin - FollowService.new.call(from_account, row.first) - rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError - next - end - end + def import_rows + CSV.new(import_contents).reject(&:blank?) end end diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb index 834b0088b..b3d8aa264 100644 --- a/app/workers/link_crawl_worker.rb +++ b/app/workers/link_crawl_worker.rb @@ -3,7 +3,7 @@ class LinkCrawlWorker include Sidekiq::Worker - sidekiq_options queue: 'pull', retry: false + sidekiq_options queue: 'pull', retry: 0 def perform(status_id) FetchLinkCardService.new.call(Status.find(status_id)) diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb index 5df404bcc..978c3aba2 100644 --- a/app/workers/processing_worker.rb +++ b/app/workers/processing_worker.rb @@ -6,6 +6,6 @@ class ProcessingWorker sidekiq_options backtrace: true def perform(account_id, body) - ProcessFeedService.new.call(body, Account.find(account_id)) + ProcessFeedService.new.call(body, Account.find(account_id), override_timestamps: true) end end diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 110b8bf16..c3506727b 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -37,7 +37,7 @@ class Pubsubhubbub::DeliveryWorker def callback_post_payload request = Request.new(:post, subscription.callback_url, body: payload) request.add_headers(headers) - request.perform + request.perform.flush end def blocked_domain? |