diff options
Diffstat (limited to 'app')
541 files changed, 33830 insertions, 1122 deletions
diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb index 414a875d0..7f69a3363 100644 --- a/app/controllers/admin/account_moderation_notes_controller.rb +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -1,31 +1,41 @@ # 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' +module Admin + class AccountModerationNotesController < BaseController + before_action :set_account_moderation_note, only: [:destroy] + + def create + authorize AccountModerationNote, :create? + + @account_moderation_note = current_account.account_moderation_notes.new(resource_params) + + if @account_moderation_note.save + redirect_to admin_account_path(@account_moderation_note.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 - 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 + def destroy + authorize @account_moderation_note, :destroy? + @account_moderation_note.destroy + redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg') + end - private + private - def resource_params - params.require(:account_moderation_note).permit( - :content, - :target_account_id - ) + def resource_params + params.require(:account_moderation_note).permit( + :content, + :target_account_id + ) + end + + def set_account_moderation_note + @account_moderation_note = AccountModerationNote.find(params[:id]) + end end end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index ffa4dc850..0829bc769 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,29 +2,54 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload] + before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :enable, :disable, :memorialize] before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] + before_action :require_local_account!, only: [:enable, :disable, :memorialize] def index + authorize :account, :index? @accounts = filtered_accounts.page(params[:page]) end def show + authorize @account, :show? @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @moderation_notes = @account.targeted_moderation_notes.latest end def subscribe + authorize @account, :subscribe? Pubsubhubbub::SubscribeWorker.perform_async(@account.id) redirect_to admin_account_path(@account.id) end def unsubscribe + authorize @account, :unsubscribe? Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) redirect_to admin_account_path(@account.id) end + def memorialize + authorize @account, :memorialize? + @account.memorialize! + redirect_to admin_account_path(@account.id) + end + + def enable + authorize @account.user, :enable? + @account.user.enable! + redirect_to admin_account_path(@account.id) + end + + def disable + authorize @account.user, :disable? + @account.user.disable! + redirect_to admin_account_path(@account.id) + end + def redownload + authorize @account, :redownload? + @account.reset_avatar! @account.reset_header! @account.save! @@ -42,6 +67,10 @@ module Admin redirect_to admin_account_path(@account.id) if @account.local? end + def require_local_account! + redirect_to admin_account_path(@account.id) unless @account.local? && @account.user.present? + end + def filtered_accounts AccountFilter.new(filter_params).results end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 11fe326bc..db4839a8f 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -2,7 +2,9 @@ module Admin class BaseController < ApplicationController - before_action :require_admin! + include Authorization + + before_action :require_staff! layout 'admin' end diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 2542e21ee..c10b0ebee 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -2,15 +2,18 @@ module Admin class ConfirmationsController < BaseController + before_action :set_user + def create - account_user.confirm + authorize @user, :confirm? + @user.confirm! redirect_to admin_accounts_path end private - def account_user - Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) + def set_user + @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end end end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 5cce5bce4..509f7a48f 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -5,14 +5,18 @@ module Admin before_action :set_custom_emoji, except: [:index, :new, :create] def index - @custom_emojis = filtered_custom_emojis.page(params[:page]) + authorize :custom_emoji, :index? + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) end def new + authorize :custom_emoji, :create? @custom_emoji = CustomEmoji.new end def create + authorize :custom_emoji, :create? + @custom_emoji = CustomEmoji.new(resource_params) if @custom_emoji.save @@ -22,13 +26,27 @@ module Admin end end + def update + authorize @custom_emoji, :update? + + if @custom_emoji.update(resource_params) + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg') + else + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg') + end + end + def destroy + authorize @custom_emoji, :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) + authorize @custom_emoji, :copy? + + emoji = CustomEmoji.find_or_initialize_by(domain: nil, shortcode: @custom_emoji.shortcode) + emoji.image = @custom_emoji.image if emoji.save flash[:notice] = I18n.t('admin.custom_emojis.copied_msg') @@ -36,15 +54,17 @@ module Admin flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg') end - redirect_to admin_custom_emojis_path(params[:page]) + redirect_to admin_custom_emojis_path(page: params[:page]) end def enable + authorize @custom_emoji, :enable? @custom_emoji.update!(disabled: false) redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg') end def disable + authorize @custom_emoji, :disable? @custom_emoji.update!(disabled: true) redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg') end @@ -56,7 +76,7 @@ module Admin end def resource_params - params.require(:custom_emoji).permit(:shortcode, :image) + params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) end def filtered_custom_emojis diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 1ab620e03..e383dc831 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -5,14 +5,18 @@ module Admin before_action :set_domain_block, only: [:show, :destroy] def index + authorize :domain_block, :index? @domain_blocks = DomainBlock.page(params[:page]) end def new + authorize :domain_block, :create? @domain_block = DomainBlock.new end def create + authorize :domain_block, :create? + @domain_block = DomainBlock.new(resource_params) if @domain_block.save @@ -23,9 +27,12 @@ module Admin end end - def show; end + def show + authorize @domain_block, :show? + end def destroy + authorize @domain_block, :destroy? UnblockDomainService.new.call(@domain_block, retroactive_unblock?) redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg') end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index 09275d5dc..01058bf46 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -5,14 +5,18 @@ module Admin before_action :set_email_domain_block, only: [:show, :destroy] def index + authorize :email_domain_block, :index? @email_domain_blocks = EmailDomainBlock.page(params[:page]) end def new + authorize :email_domain_block, :create? @email_domain_block = EmailDomainBlock.new end def create + authorize :email_domain_block, :create? + @email_domain_block = EmailDomainBlock.new(resource_params) if @email_domain_block.save @@ -23,6 +27,7 @@ module Admin end def destroy + authorize @email_domain_block, :destroy? @email_domain_block.destroy redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg') end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 22f02e5d0..8ed0ea421 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -3,10 +3,12 @@ module Admin class InstancesController < BaseController def index + authorize :instance, :index? @instances = ordered_instances end def resubscribe + authorize :instance, :resubscribe? params.require(:by_domain) Pubsubhubbub::SubscribeWorker.push_bulk(subscribeable_accounts.pluck(:id)) redirect_to admin_instances_path diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb index 5a31adecf..4f66ce708 100644 --- a/app/controllers/admin/reported_statuses_controller.rb +++ b/app/controllers/admin/reported_statuses_controller.rb @@ -2,19 +2,20 @@ module Admin class ReportedStatusesController < BaseController - include Authorization - before_action :set_report before_action :set_status, only: [:update, :destroy] def create - @form = Form::StatusBatch.new(form_status_batch_params) - flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save + authorize :status, :update? + + @form = Form::StatusBatch.new(form_status_batch_params) + flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save redirect_to admin_report_path(@report) end def update + authorize @status, :update? @status.update(status_params) redirect_to admin_report_path(@report) end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 226467739..745757ee8 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -5,14 +5,17 @@ module Admin before_action :set_report, except: [:index] def index + authorize :report, :index? @reports = filtered_reports.page(params[:page]) end def show + authorize @report, :show? @form = Form::StatusBatch.new end def update + authorize @report, :update? process_report redirect_to admin_report_path(@report) end diff --git a/app/controllers/admin/resets_controller.rb b/app/controllers/admin/resets_controller.rb index 6db648403..00b590bf6 100644 --- a/app/controllers/admin/resets_controller.rb +++ b/app/controllers/admin/resets_controller.rb @@ -2,17 +2,18 @@ module Admin class ResetsController < BaseController - before_action :set_account + before_action :set_user def create - @account.user.send_reset_password_instructions + authorize @user, :reset_password? + @user.send_reset_password_instructions redirect_to admin_accounts_path end private - def set_account - @account = Account.find(params[:account_id]) + def set_user + @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end end end diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb new file mode 100644 index 000000000..8f8685827 --- /dev/null +++ b/app/controllers/admin/roles_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Admin + class RolesController < BaseController + before_action :set_user + + def promote + authorize @user, :promote? + @user.promote! + redirect_to admin_account_path(@user.account_id) + end + + def demote + authorize @user, :demote? + @user.demote! + redirect_to admin_account_path(@user.account_id) + end + + private + + def set_user + @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) + end + end +end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index a2f86b8a9..e81290228 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -28,10 +28,13 @@ module Admin ).freeze def edit + authorize :settings, :show? @admin_settings = Form::AdminSettings.new end def update + authorize :settings, :update? + settings_params.each do |key, value| if UPLOAD_SETTINGS.include?(key) upload = SiteUpload.where(var: key).first_or_initialize(var: key) diff --git a/app/controllers/admin/silences_controller.rb b/app/controllers/admin/silences_controller.rb index 81a3008b9..01fb292de 100644 --- a/app/controllers/admin/silences_controller.rb +++ b/app/controllers/admin/silences_controller.rb @@ -5,11 +5,13 @@ module Admin before_action :set_account def create + authorize @account, :silence? @account.update(silenced: true) redirect_to admin_accounts_path end def destroy + authorize @account, :unsilence? @account.update(silenced: false) redirect_to admin_accounts_path end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index b05000b16..b54a9b824 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -2,8 +2,6 @@ module Admin class StatusesController < BaseController - include Authorization - helper_method :current_params before_action :set_account @@ -12,24 +10,30 @@ module Admin PER_PAGE = 20 def index + authorize :status, :index? + @statuses = @account.statuses + if params[:media] account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct @statuses.merge!(Status.where(id: account_media_status_ids)) end - @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) - @form = Form::StatusBatch.new + @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) + @form = Form::StatusBatch.new end def create - @form = Form::StatusBatch.new(form_status_batch_params) - flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save + authorize :status, :update? + + @form = Form::StatusBatch.new(form_status_batch_params) + flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save redirect_to admin_account_statuses_path(@account.id, current_params) end def update + authorize @status, :update? @status.update(status_params) redirect_to admin_account_statuses_path(@account.id, current_params) end @@ -60,6 +64,7 @@ module Admin def current_params page = (params[:page] || 1).to_i + { media: params[:media], page: page > 1 && page, diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb index 624a475a3..40500ef43 100644 --- a/app/controllers/admin/subscriptions_controller.rb +++ b/app/controllers/admin/subscriptions_controller.rb @@ -3,6 +3,7 @@ module Admin class SubscriptionsController < BaseController def index + authorize :subscription, :index? @subscriptions = ordered_subscriptions.page(requested_page) end diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb index 5d9048d94..778feea5e 100644 --- a/app/controllers/admin/suspensions_controller.rb +++ b/app/controllers/admin/suspensions_controller.rb @@ -5,12 +5,14 @@ module Admin before_action :set_account def create + authorize @account, :suspend? Admin::SuspensionWorker.perform_async(@account.id) redirect_to admin_accounts_path end def destroy - @account.update(suspended: false) + authorize @account, :unsuspend? + @account.unsuspend! redirect_to admin_accounts_path end diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb index 69c08f605..5a45d25cd 100644 --- a/app/controllers/admin/two_factor_authentications_controller.rb +++ b/app/controllers/admin/two_factor_authentications_controller.rb @@ -5,6 +5,7 @@ module Admin before_action :set_user def destroy + authorize @user, :disable_2fa? @user.disable_two_factor! redirect_to admin_accounts_path end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index b3fc4e561..85eb2d60e 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,11 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account.acct) + reblogs_arg = { reblogs: params[:reblogs] } + + FollowService.new.call(current_user.account, @account.acct, reblogs_arg) - options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: { @account.id => reblogs_arg }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end @@ -26,7 +28,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account) + MuteService.new.call(current_user.account, @account, notifications: params[:notifications]) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb new file mode 100644 index 000000000..40c485e8d --- /dev/null +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class Api::V1::Lists::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:show] + before_action -> { doorkeeper_authorize! :write }, except: [:show] + + before_action :require_user! + before_action :set_list + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = @list.accounts.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + ApplicationRecord.transaction do + list_accounts.each do |account| + @list.accounts << account + end + end + + render_empty + end + + def destroy + ListAccount.where(list: @list, account_id: account_ids).destroy_all + render_empty + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:list_id]) + end + + def list_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_list_accounts_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb new file mode 100644 index 000000000..9437373bd --- /dev/null +++ b/app/controllers/api/v1/lists_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class Api::V1::ListsController < Api::BaseController + LISTS_LIMIT = 50 + + before_action -> { doorkeeper_authorize! :read }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write }, except: [:index, :show] + + before_action :require_user! + before_action :set_list, except: [:index, :create] + + after_action :insert_pagination_headers, only: :index + + def index + @lists = List.where(account: current_account).paginate_by_max_id(limit_param(LISTS_LIMIT), params[:max_id], params[:since_id]) + render json: @lists, each_serializer: REST::ListSerializer + end + + def show + render json: @list, serializer: REST::ListSerializer + end + + def create + @list = List.create!(list_params.merge(account: current_account)) + render json: @list, serializer: REST::ListSerializer + end + + def update + @list.update!(list_params) + render json: @list, serializer: REST::ListSerializer + end + + def destroy + @list.destroy! + render_empty + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:id]) + end + + def list_params + params.permit(:title) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_lists_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @lists.empty? + api_v1_lists_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @lists.last.id + end + + def pagination_since_id + @lists.first.id + end + + def records_continue? + @lists.size == limit_param(LISTS_LIMIT) + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 0c43cb943..92ad251ef 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -8,10 +8,15 @@ class Api::V1::MutesController < Api::BaseController respond_to :json def index - @accounts = load_accounts + @data = @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end + def details + @data = @mutes = load_mutes + render json: @mutes, each_serializer: REST::MuteSerializer + end + private def load_accounts @@ -22,6 +27,10 @@ class Api::V1::MutesController < Api::BaseController Account.includes(:muted_by).references(:muted_by) end + def load_mutes + paginated_mutes.includes(:account, :target_account).to_a + end + def paginated_mutes Mute.where(account: current_account).paginate_by_max_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), @@ -36,26 +45,34 @@ class Api::V1::MutesController < Api::BaseController def next_path if records_continue? - api_v1_mutes_url pagination_params(max_id: pagination_max_id) + url_for pagination_params(max_id: pagination_max_id) end end def prev_path - unless @accounts.empty? - api_v1_mutes_url pagination_params(since_id: pagination_since_id) + unless@data.empty? + url_for pagination_params(since_id: pagination_since_id) end end def pagination_max_id - @accounts.last.muted_by_ids.last + if params[:action] == "details" + @mutes.last.id + else + @accounts.last.muted_by_ids.last + end end def pagination_since_id - @accounts.first.muted_by_ids.first + if params[:action] == "details" + @mutes.first.id + else + @accounts.first.muted_by_ids.first + end end def records_continue? - @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end def pagination_params(core_params) diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 8910b77e9..a949752fb 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -24,11 +24,20 @@ class Api::V1::NotificationsController < Api::BaseController render_empty end + def destroy + dismiss + end + def dismiss current_account.notifications.find_by!(id: params[:id]).destroy! render_empty end + def destroy_multiple + current_account.notifications.where(id: params[:ids]).destroy_all + render_empty + end + private def load_notifications diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 9592cd4bd..22828217d 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -19,7 +19,7 @@ class Api::V1::ReportsController < Api::BaseController comment: report_params[:comment] ) - User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } + User.staff.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } render json: @report, serializer: REST::ReportSerializer end diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index bc5b8e5d4..d1b4e0402 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class Api::V1::SearchController < Api::BaseController - RESULTS_LIMIT = 5 + include Authorization + + RESULTS_LIMIT = 10 before_action -> { doorkeeper_authorize! :read } before_action :require_user! @@ -9,12 +11,24 @@ class Api::V1::SearchController < Api::BaseController respond_to :json def index - @search = Search.new(search_results) + @search = Search.new(search) render json: @search, serializer: REST::SearchSerializer end private + def search + search_results.tap do |search| + search[:statuses].keep_if do |status| + begin + authorize status, :show? + rescue Mastodon::NotPermittedError + false + end + end + end + end + def search_results SearchService.new.call( params[:q], diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb new file mode 100644 index 000000000..d455227eb --- /dev/null +++ b/app/controllers/api/v1/timelines/direct_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::DirectController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:show] + before_action :require_user!, only: [:show] + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + respond_to :json + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_direct_statuses + end + + def cached_direct_statuses + cache_collection direct_statuses, Status + end + + def direct_statuses + direct_timeline_statuses.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def direct_timeline_statuses + Status.as_direct_timeline(current_account) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 3dd27710c..db6cd8568 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -31,7 +31,7 @@ class Api::V1::Timelines::HomeController < Api::BaseController end def account_home_feed - Feed.new(:home, current_account) + HomeFeed.new(current_account) end def insert_pagination_headers diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb new file mode 100644 index 000000000..f5db71e46 --- /dev/null +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::ListController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + before_action :set_list + before_action :set_statuses + + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + def show + render json: @statuses, + each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:id]) + end + + def set_statuses + @statuses = cached_list_statuses + end + + def cached_list_statuses + cache_collection list_statuses, Status + end + + def list_statuses + list_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def list_feed + ListFeed.new(@list) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end + + def next_path + api_v1_timelines_list_url params[:id], pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_list_url params[:id], pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d5eca6ffb..f5dbe837e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,11 +13,13 @@ class ApplicationController < ActionController::Base helper_method :current_account helper_method :current_session helper_method :current_theme + helper_method :theme_data helper_method :single_user_mode? rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity + rescue_from Mastodon::NotPermittedError, with: :forbidden before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :check_suspension, if: :user_signed_in? @@ -40,6 +42,10 @@ class ApplicationController < ActionController::Base redirect_to root_path unless current_user&.admin? end + def require_staff! + redirect_to root_path unless current_user&.staff? + end + def check_suspension forbidden if current_user.account.suspended? end @@ -83,6 +89,10 @@ class ApplicationController < ActionController::Base current_user.setting_theme end + def theme_data + Themes.instance.get(current_theme) + end + def cache_collection(raw, klass) return raw unless klass.respond_to?(:with_includes) @@ -99,7 +109,7 @@ class ApplicationController < ActionController::Base unless uncached_ids.empty? uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h - uncached.values.each do |item| + uncached.each_value do |item| Rails.cache.write(item.cache_key, item) end end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 463a183e4..a5acb6c36 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -62,7 +62,7 @@ class Auth::SessionsController < Devise::SessionsController if user_params[:otp_attempt].present? && session[:otp_user_id] authenticate_with_two_factor_via_otp(user) - elsif user && user.valid_password?(user_params[:password]) + elsif user&.valid_password?(user_params[:password]) prompt_for_two_factor(user) end end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 7828fe48d..95a37e379 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -2,6 +2,7 @@ module Authorization extend ActiveSupport::Concern + include Pundit def pundit_user diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb new file mode 100644 index 000000000..f79e1b320 --- /dev/null +++ b/app/controllers/settings/keyword_mutes_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class Settings::KeywordMutesController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :load_keyword_mute, only: [:edit, :update, :destroy] + + def index + @keyword_mutes = paginated_keyword_mutes_for_account + end + + def new + @keyword_mute = keyword_mutes_for_account.build + end + + def create + @keyword_mute = keyword_mutes_for_account.create(keyword_mute_params) + + if @keyword_mute.persisted? + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + else + render :new + end + end + + def update + if @keyword_mute.update(keyword_mute_params) + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + else + render :edit + end + end + + def destroy + @keyword_mute.destroy! + + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + end + + def destroy_all + keyword_mutes_for_account.delete_all + + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + end + + private + + def keyword_mutes_for_account + Glitch::KeywordMute.where(account: current_account) + end + + def load_keyword_mute + @keyword_mute = keyword_mutes_for_account.find(params[:id]) + end + + def keyword_mute_params + params.require(:keyword_mute).permit(:keyword, :whole_word) + end + + def paginated_keyword_mutes_for_account + keyword_mutes_for_account.order(:keyword).page params[:page] + end +end diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb index 09839f16e..ce2530c54 100644 --- a/app/controllers/settings/notifications_controller.rb +++ b/app/controllers/settings/notifications_controller.rb @@ -26,7 +26,7 @@ class Settings::NotificationsController < ApplicationController 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) + interactions: %i(must_be_follower must_be_following must_be_following_dm) ) end end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index cc579dbc8..5f61e2182 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -48,7 +48,7 @@ class StreamEntriesController < ApplicationController @type = @stream_entry.activity_type.downcase raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? - authorize @stream_entry.activity, :show? if @stream_entry.hidden? + authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only? rescue Mastodon::NotPermittedError # Reraise in order to get a 404 raise ActiveRecord::RecordNotFound diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 6a57b3d63..e0fae9d9a 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -18,7 +18,7 @@ module Admin::FilterHelper def selected?(more_params) new_url = filtered_url_for(more_params) - filter_link_class(new_url) == 'selected' ? true : false + filter_link_class(new_url) == 'selected' end private diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6d625e7db..7dfab1df1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -35,6 +35,11 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end + def can?(action, record) + return false if record.nil? + policy(record).public_send("#{action}?") + end + def fa_icon(icon, attributes = {}) class_names = attributes[:class]&.split(' ') || [] class_names << 'fa' @@ -43,6 +48,10 @@ module ApplicationHelper content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) end + def custom_emoji_tag(custom_emoji) + image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:") + end + def opengraph(property, content) tag(:meta, content: content, property: property) end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index c23a2e095..a3441e6f9 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -9,6 +9,10 @@ module JsonLdHelper value.is_a?(Array) ? value.first : value end + def as_array(value) + value.is_a?(Array) ? value : [value] + end + def value_or_id(value) value.is_a?(String) || value.nil? ? value : value['id'] end diff --git a/app/helpers/settings/keyword_mutes_helper.rb b/app/helpers/settings/keyword_mutes_helper.rb new file mode 100644 index 000000000..7b98cd59e --- /dev/null +++ b/app/helpers/settings/keyword_mutes_helper.rb @@ -0,0 +1,2 @@ +module Settings::KeywordMutesHelper +end diff --git a/app/javascript/core/about.js b/app/javascript/core/about.js new file mode 100644 index 000000000..6ed0e4ad3 --- /dev/null +++ b/app/javascript/core/about.js @@ -0,0 +1 @@ +// This file will be loaded on about pages, regardless of theme. diff --git a/app/javascript/packs/admin.js b/app/javascript/core/admin.js index 993827db5..c0bd09bdd 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/core/admin.js @@ -1,3 +1,5 @@ +// This file will be loaded on admin pages, regardless of theme. + import { delegate } from 'rails-ujs'; function handleDeleteStatus(event) { diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js new file mode 100644 index 000000000..24c0fdf61 --- /dev/null +++ b/app/javascript/core/common.js @@ -0,0 +1,5 @@ +// This file will be loaded on all pages, regardless of theme. + +import { start } from 'rails-ujs'; + +start(); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js new file mode 100644 index 000000000..8167706a3 --- /dev/null +++ b/app/javascript/core/embed.js @@ -0,0 +1,23 @@ +// This file will be loaded on embed pages, regardless of theme. + +window.addEventListener('message', e => { + const data = e.data || {}; + + if (!window.parent || data.type !== 'setHeight') { + return; + } + + function setEmbedHeight () { + window.parent.postMessage({ + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0].scrollHeight, + }, '*'); + }); + + if (['interactive', 'complete'].includes(document.readyState)) { + setEmbedHeight(); + } else { + document.addEventListener('DOMContentLoaded', setEmbedHeight); + } +}); diff --git a/app/javascript/core/home.js b/app/javascript/core/home.js new file mode 100644 index 000000000..3c2e01590 --- /dev/null +++ b/app/javascript/core/home.js @@ -0,0 +1 @@ +// This file will be loaded on home pages, regardless of theme. diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js new file mode 100644 index 000000000..1a36b7a5f --- /dev/null +++ b/app/javascript/core/public.js @@ -0,0 +1 @@ +// This file will be loaded on public pages, regardless of theme. diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js new file mode 100644 index 000000000..91332ed5a --- /dev/null +++ b/app/javascript/core/settings.js @@ -0,0 +1,65 @@ +// This file will be loaded on settings pages, regardless of theme. + +function main() { + const { length } = require('stringz'); + const { delegate } = require('rails-ujs'); + + delegate(document, '.webapp-btn', 'click', ({ target, button }) => { + if (button !== 0) { + return true; + } + window.location.href = target.href; + return false; + }); + + delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => { + const contentEl = target.parentNode.parentNode.querySelector('.e-content'); + + if (contentEl.style.display === 'block') { + contentEl.style.display = 'none'; + target.parentNode.style.marginBottom = 0; + } else { + contentEl.style.display = 'block'; + target.parentNode.style.marginBottom = null; + } + + return false; + }); + + delegate(document, '.account_display_name', 'input', ({ target }) => { + const nameCounter = document.querySelector('.name-counter'); + + if (nameCounter) { + nameCounter.textContent = 30 - length(target.value); + } + }); + + delegate(document, '.account_note', 'input', ({ target }) => { + const noteCounter = document.querySelector('.note-counter'); + + if (noteCounter) { + const noteWithoutMetadata = processBio(target.value).text; + noteCounter.textContent = 500 - length(noteWithoutMetadata); + } + }); + + delegate(document, '#account_avatar', 'change', ({ target }) => { + const avatar = document.querySelector('.card.compact .avatar img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + avatar.src = url; + }); + + delegate(document, '#account_header', 'change', ({ target }) => { + const header = document.querySelector('.card.compact'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; + + header.style.backgroundImage = `url(${url})`; + }); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/core/share.js b/app/javascript/core/share.js new file mode 100644 index 000000000..98a413632 --- /dev/null +++ b/app/javascript/core/share.js @@ -0,0 +1 @@ +// This file will be loaded on share pages, regardless of theme. diff --git a/app/javascript/glitch/locales/en.json b/app/javascript/glitch/locales/en.json new file mode 100644 index 000000000..69aa29108 --- /dev/null +++ b/app/javascript/glitch/locales/en.json @@ -0,0 +1,44 @@ +{ + "getting_started.open_source_notice": "Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.", + "layout.auto": "Auto", + "layout.current_is": "Your current layout is:", + "layout.desktop": "Desktop", + "layout.mobile": "Mobile", + "navigation_bar.app_settings": "App settings", + "getting_started.onboarding": "Show me around", + "onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.welcome": "Welcome to {domain}!", + "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.", + "settings.auto_collapse": "Automatic collapsing", + "settings.auto_collapse_all": "Everything", + "settings.auto_collapse_lengthy": "Lengthy toots", + "settings.auto_collapse_media": "Toots with media", + "settings.auto_collapse_notifications": "Notifications", + "settings.auto_collapse_reblogs": "Boosts", + "settings.auto_collapse_replies": "Replies", + "settings.close": "Close", + "settings.collapsed_statuses": "Collapsed toots", + "settings.enable_collapsed": "Enable collapsed toots", + "settings.general": "General", + "settings.image_backgrounds": "Image backgrounds", + "settings.image_backgrounds_media": "Preview collapsed toot media", + "settings.image_backgrounds_users": "Give collapsed toots an image background", + "settings.media": "Media", + "settings.media_letterbox": "Letterbox media", + "settings.media_fullwidth": "Full-width media previews", + "settings.preferences": "User preferences", + "settings.wide_view": "Wide view (Desktop mode only)", + "settings.navbar_under": "Navbar at the bottom (Mobile only)", + "status.collapse": "Collapse", + "status.uncollapse": "Uncollapse", + + "notification.markForDeletion": "Mark for deletion", + "notifications.clear": "Clear all my notifications", + "notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?", + "notifications.marked_clear": "Clear selected notifications", + + "notification_purge.btn_all": "Select\nall", + "notification_purge.btn_none": "Select\nnone", + "notification_purge.btn_invert": "Invert\nselection", + "notification_purge.btn_apply": "Clear\nselected" +} diff --git a/app/javascript/images/mastodon-getting-started.png b/app/javascript/images/mastodon-getting-started.png index e05dd493f..8fe0df76a 100644 --- a/app/javascript/images/mastodon-getting-started.png +++ b/app/javascript/images/mastodon-getting-started.png Binary files differdiff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 73d6baace..fbaebf786 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -241,11 +241,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id) { +export function muteAccount(id, notifications) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index febda7219..3474250fe 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -1,5 +1,6 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; +import { openModal } from '../../mastodon/actions/modal'; export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; @@ -9,6 +10,9 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; +export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; + export function fetchMutes() { return (dispatch, getState) => { dispatch(fetchMutesRequest()); @@ -80,3 +84,20 @@ export function expandMutesFail(error) { error, }; }; + +export function initMuteModal(account) { + return dispatch => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal('MUTE')); + }; +} + +export function toggleHideNotifications() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; +} \ No newline at end of file diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js index 01bf8930b..3f40f6c2d 100644 --- a/app/javascript/mastodon/actions/pin_statuses.js +++ b/app/javascript/mastodon/actions/pin_statuses.js @@ -4,12 +4,13 @@ export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; +import { me } from '../initial_state'; + export function fetchPinnedStatuses() { return (dispatch, getState) => { dispatch(fetchPinnedStatusesRequest()); - const accountId = getState().getIn(['meta', 'me']); - api(getState).get(`/api/v1/accounts/${accountId}/statuses`, { params: { pinned: true } }).then(response => { + api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { dispatch(fetchPinnedStatusesSuccess(response.data, null)); }).catch(error => { dispatch(fetchPinnedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 7802694a3..dcce048ca 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -1,4 +1,4 @@ -import createStream from '../stream'; +import { connectStream } from '../stream'; import { updateTimeline, deleteFromTimelines, @@ -12,42 +12,19 @@ import { getLocale } from '../locales'; const { messages } = getLocale(); export function connectTimelineStream (timelineId, path, pollingRefresh = null) { - return (dispatch, getState) => { - const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); - const accessToken = getState().getIn(['meta', 'access_token']); - const locale = getState().getIn(['meta', 'locale']); - let polling = null; - - const setupPolling = () => { - polling = setInterval(() => { - pollingRefresh(dispatch); - }, 20000); - }; - - const clearPolling = () => { - if (polling) { - clearInterval(polling); - polling = null; - } - }; - - const subscription = createStream(streamingAPIBaseURL, accessToken, path, { - connected () { - if (pollingRefresh) { - clearPolling(); - } + return connectStream (path, pollingRefresh, (dispatch, getState) => { + const locale = getState().getIn(['meta', 'locale']); + return { + onConnect() { dispatch(connectTimeline(timelineId)); }, - disconnected () { - if (pollingRefresh) { - setupPolling(); - } + onDisconnect() { dispatch(disconnectTimeline(timelineId)); }, - received (data) { + onReceive (data) { switch(data.event) { case 'update': dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); @@ -60,26 +37,8 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) break; } }, - - reconnected () { - if (pollingRefresh) { - clearPolling(); - pollingRefresh(dispatch); - } - dispatch(connectTimeline(timelineId)); - }, - - }); - - const disconnect = () => { - if (subscription) { - subscription.close(); - } - clearPolling(); }; - - return disconnect; - }; + }); } function refreshHomeTimelineAndNotification (dispatch) { diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index d614a52c9..724b10980 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -7,6 +7,7 @@ import Permalink from './permalink'; import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from '../initial_state'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -14,6 +15,8 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, + unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, }); @injectIntl @@ -21,7 +24,6 @@ export default class Account extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, - me: PropTypes.string.isRequired, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, @@ -41,8 +43,16 @@ export default class Account extends ImmutablePureComponent { this.props.onMute(this.props.account); } + handleMuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, true); + } + + handleUnmuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, false); + } + render () { - const { account, me, intl, hidden } = this.props; + const { account, intl, hidden } = this.props; if (!account) { return <div />; @@ -70,7 +80,18 @@ export default class Account extends ImmutablePureComponent { } else if (blocking) { buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; } else if (muting) { - buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />; + let hidingNotificationsButton; + if (muting.get('notifications')) { + hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; + } else { + hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; + } + buttons = ( + <div> + <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} /> + {hidingNotificationsButton} + </div> + ); } else { buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; } diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index e4fa8fa7a..80a8fbdb3 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -137,7 +137,9 @@ export default class ColumnHeader extends React.PureComponent { <div className={wrapperClassName}> <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} + <span className='column-header__title'> + {title} + </span> <div className='column-header__buttons'> {backButton} diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index d8e445cef..06f53841d 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -72,6 +72,25 @@ export default class IconButton extends React.PureComponent { overlayed: overlay, }); + if (!animate) { + // Perf optimization: avoid unnecessary <Motion> components unless + // we actually need to animate. + return ( + <button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + style={style} + tabIndex={tabIndex} + > + <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> + </button> + ); + } + return ( <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> {({ rotate }) => diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index fb71d8c5c..20febdb16 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,6 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; +import { autoPlayGif } from '../initial_state'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, @@ -23,11 +24,9 @@ class Item extends React.PureComponent { index: PropTypes.number.isRequired, size: PropTypes.number.isRequired, onClick: PropTypes.func.isRequired, - autoPlayGif: PropTypes.bool, }; static defaultProps = { - autoPlayGif: false, standalone: false, index: 0, size: 1, @@ -47,7 +46,7 @@ class Item extends React.PureComponent { } hoverToPlay () { - const { attachment, autoPlayGif } = this.props; + const { attachment } = this.props; return !autoPlayGif && attachment.get('type') === 'gifv'; } @@ -139,7 +138,7 @@ class Item extends React.PureComponent { </a> ); } else if (attachment.get('type') === 'gifv') { - const autoPlay = !isIOS() && this.props.autoPlayGif; + const autoPlay = !isIOS() && autoPlayGif; thumbnail = ( <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> @@ -181,11 +180,9 @@ export default class MediaGallery extends React.PureComponent { height: PropTypes.number.isRequired, onOpenMedia: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, - autoPlayGif: PropTypes.bool, }; static defaultProps = { - autoPlayGif: false, standalone: false, }; @@ -261,9 +258,9 @@ export default class MediaGallery extends React.PureComponent { const size = media.take(4).size; if (this.isStandaloneEligible()) { - children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />; + children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />; } 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} />); + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); } } diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index ab9d48510..71228ca6c 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -1,5 +1,5 @@ import React, { PureComponent } from 'react'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import PropTypes from 'prop-types'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import LoadMore from './load_more'; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 70005436b..d23ff87fa 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -36,9 +36,6 @@ export default class Status extends ImmutablePureComponent { onBlock: PropTypes.func, onEmbed: PropTypes.func, onHeightChange: PropTypes.func, - me: PropTypes.string, - boostModal: PropTypes.bool, - autoPlayGif: PropTypes.bool, muted: PropTypes.bool, hidden: PropTypes.bool, onMoveUp: PropTypes.func, @@ -54,9 +51,6 @@ export default class Status extends ImmutablePureComponent { updateOnProps = [ 'status', 'account', - 'me', - 'boostModal', - 'autoPlayGif', 'muted', 'hidden', ] @@ -197,7 +191,7 @@ export default class Status extends ImmutablePureComponent { } else { media = ( <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > - {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />} + {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />} </Bundle> ); } diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index e952733f3..7021c198e 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -5,6 +5,7 @@ import IconButton from './icon_button'; import DropdownMenuContainer from '../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from '../initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -47,7 +48,6 @@ export default class StatusActionBar extends ImmutablePureComponent { onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, onPin: PropTypes.func, - me: PropTypes.string, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -56,7 +56,6 @@ export default class StatusActionBar extends ImmutablePureComponent { // evaluate to false. See react-immutable-pure-component for usage. updateOnProps = [ 'status', - 'me', 'withDismiss', ] @@ -116,7 +115,7 @@ export default class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, me, intl, withDismiss } = this.props; + const { status, intl, withDismiss } = this.props; const mutingConversation = status.get('muted'); const anonymousAccess = !me; diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js index 7c77cb764..5a5136dd1 100644 --- a/app/javascript/mastodon/containers/account_container.js +++ b/app/javascript/mastodon/containers/account_container.js @@ -12,6 +12,8 @@ import { unmuteAccount, } from '../actions/accounts'; import { openModal } from '../actions/modal'; +import { initMuteModal } from '../actions/mutes'; +import { unfollowModal } from '../initial_state'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, @@ -22,8 +24,6 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ account: getAccount(state, props.id), - me: state.getIn(['meta', 'me']), - unfollowModal: state.getIn(['meta', 'unfollow_modal']), }); return mapStateToProps; @@ -33,7 +33,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { - if (this.unfollowModal) { + if (unfollowModal) { dispatch(openModal('CONFIRM', { message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, confirm: intl.formatMessage(messages.unfollowConfirm), @@ -59,10 +59,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (account.getIn(['relationship', 'muting'])) { dispatch(unmuteAccount(account.get('id'))); } else { - dispatch(muteAccount(account.get('id'))); + dispatch(initMuteModal(account)); } }, + + onMuteNotifications (account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js index db452d03a..5ee1d2f14 100644 --- a/app/javascript/mastodon/containers/compose_container.js +++ b/app/javascript/mastodon/containers/compose_container.js @@ -6,15 +6,14 @@ import { hydrateStore } from '../actions/store'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; import Compose from '../features/standalone/compose'; +import initialState from '../initial_state'; const { localeData, messages } = getLocale(); addLocaleData(localeData); const store = configureStore(); -const initialStateContainer = document.getElementById('initial-state'); -if (initialStateContainer !== null) { - const initialState = JSON.parse(initialStateContainer.textContent); +if (initialState) { store.dispatch(hydrateStore(initialState)); } diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 56b7bda46..d1710445b 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -4,18 +4,19 @@ import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; import { showOnboardingOnce } from '../actions/onboarding'; import { BrowserRouter, Route } from 'react-router-dom'; -import { ScrollContext } from 'react-router-scroll'; +import { ScrollContext } from 'react-router-scroll-4'; import UI from '../features/ui'; import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; +import initialState from '../initial_state'; const { localeData, messages } = getLocale(); addLocaleData(localeData); export const store = configureStore(); -const hydrateAction = hydrateStore(JSON.parse(document.getElementById('initial-state').textContent)); +const hydrateAction = hydrateStore(initialState); store.dispatch(hydrateAction); export default class Mastodon extends React.PureComponent { diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index c61b7d00d..b22540204 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -14,20 +14,18 @@ import { pin, unpin, } from '../actions/interactions'; -import { - blockAccount, - muteAccount, -} from '../actions/accounts'; +import { blockAccount } from '../actions/accounts'; import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; +import { initMuteModal } from '../actions/mutes'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { boostModal, deleteModal } from '../initial_state'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, }); const makeMapStateToProps = () => { @@ -35,10 +33,6 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props.id), - me: state.getIn(['meta', 'me']), - boostModal: state.getIn(['meta', 'boost_modal']), - deleteModal: state.getIn(['meta', 'delete_modal']), - autoPlayGif: state.getIn(['meta', 'auto_play_gif']), }); return mapStateToProps; @@ -58,7 +52,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (status.get('reblogged')) { dispatch(unreblog(status)); } else { - if (e.shiftKey || !this.boostModal) { + if (e.shiftKey || !boostModal) { this.onModalReblog(status); } else { dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); @@ -87,7 +81,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, onDelete (status) { - if (!this.deleteModal) { + if (!deleteModal) { dispatch(deleteStatus(status.get('id'))); } else { dispatch(openModal('CONFIRM', { @@ -123,11 +117,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, onMute (account) { - dispatch(openModal('CONFIRM', { - message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, - confirm: intl.formatMessage(messages.muteConfirm), - onConfirm: () => dispatch(muteAccount(account.get('id'))), - })); + dispatch(initMuteModal(account)); }, onMuteConversation (status) { diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js index 4be037955..e84c921ee 100644 --- a/app/javascript/mastodon/containers/timeline_container.js +++ b/app/javascript/mastodon/containers/timeline_container.js @@ -7,15 +7,14 @@ import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; import PublicTimeline from '../features/standalone/public_timeline'; import HashtagTimeline from '../features/standalone/hashtag_timeline'; +import initialState from '../initial_state'; const { localeData, messages } = getLocale(); addLocaleData(localeData); const store = configureStore(); -const initialStateContainer = document.getElementById('initial-state'); -if (initialStateContainer !== null) { - const initialState = JSON.parse(initialStateContainer.textContent); +if (initialState) { store.dispatch(hydrateStore(initialState)); } diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index 2819ae252..e375131d4 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import { Link } from 'react-router-dom'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; +import { me } from '../../../initial_state'; const messages = defineMessages({ mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, @@ -26,7 +27,6 @@ export default class ActionBar extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, - me: PropTypes.string.isRequired, onFollow: PropTypes.func, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, @@ -44,7 +44,7 @@ export default class ActionBar extends React.PureComponent { } render () { - const { account, me, intl } = this.props; + const { account, intl } = this.props; let menu = []; let extraInfo = ''; diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 07a6c5dec..f0d2d481f 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -5,8 +5,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from '../../../components/icon_button'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; -import { connect } from 'react-redux'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { autoPlayGif, me } from '../../../initial_state'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -14,19 +14,10 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, }); -const makeMapStateToProps = () => { - const mapStateToProps = state => ({ - autoPlayGif: state.getIn(['meta', 'auto_play_gif']), - }); - - return mapStateToProps; -}; - class Avatar extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, - autoPlayGif: PropTypes.bool.isRequired, }; state = { @@ -44,7 +35,7 @@ class Avatar extends ImmutablePureComponent { } render () { - const { account, autoPlayGif } = this.props; + const { account } = this.props; const { isHovered } = this.state; return ( @@ -71,20 +62,17 @@ class Avatar extends ImmutablePureComponent { } -@connect(makeMapStateToProps) @injectIntl export default class Header extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map, - me: PropTypes.string.isRequired, onFollow: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, - autoPlayGif: PropTypes.bool.isRequired, }; render () { - const { account, me, intl } = this.props; + const { account, intl } = this.props; if (!account) { return null; @@ -124,7 +112,7 @@ export default class Header extends ImmutablePureComponent { return ( <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> <div> - <Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> + <Avatar account={account} /> <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} /> <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 2a88addc4..a40722417 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -12,14 +12,13 @@ import { getAccountGallery } from '../../selectors'; import MediaItem from './components/media_item'; import HeaderContainer from '../account_timeline/containers/header_container'; import { FormattedMessage } from 'react-intl'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import LoadMore from '../../components/load_more'; const mapStateToProps = (state, props) => ({ medias: getAccountGallery(state, props.params.accountId), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), - autoPlayGif: state.getIn(['meta', 'auto_play_gif']), }); @connect(mapStateToProps) @@ -31,7 +30,6 @@ export default class AccountGallery extends ImmutablePureComponent { medias: ImmutablePropTypes.list.isRequired, isLoading: PropTypes.bool, hasMore: PropTypes.bool, - autoPlayGif: PropTypes.bool, }; componentDidMount () { @@ -67,7 +65,7 @@ export default class AccountGallery extends ImmutablePureComponent { } render () { - const { medias, autoPlayGif, isLoading, hasMore } = this.props; + const { medias, isLoading, hasMore } = this.props; let loadMore = null; @@ -100,7 +98,6 @@ export default class AccountGallery extends ImmutablePureComponent { <MediaItem key={media.get('id')} media={media} - autoPlayGif={autoPlayGif} /> )} {loadMore} diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index edfedb864..8cf7b92ca 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -10,7 +10,6 @@ export default class Header extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map, - me: PropTypes.string.isRequired, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, @@ -61,7 +60,7 @@ export default class Header extends ImmutablePureComponent { } render () { - const { account, me } = this.props; + const { account } = this.props; if (account === null) { return <MissingIndicator />; @@ -71,13 +70,11 @@ export default class Header extends ImmutablePureComponent { <div className='account-timeline__header'> <InnerHeader account={account} - me={me} onFollow={this.handleFollow} /> <ActionBar account={account} - me={me} onBlock={this.handleBlock} onMention={this.handleMention} onReport={this.handleReport} diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index ab75b40de..8e50ec405 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -7,19 +7,19 @@ import { unfollowAccount, blockAccount, unblockAccount, - muteAccount, unmuteAccount, } from '../../../actions/accounts'; import { mentionCompose } from '../../../actions/compose'; +import { initMuteModal } from '../../../actions/mutes'; import { initReport } from '../../../actions/reports'; import { openModal } from '../../../actions/modal'; import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { unfollowModal } from '../../../initial_state'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); @@ -28,8 +28,6 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, { accountId }) => ({ account: getAccount(state, accountId), - me: state.getIn(['meta', 'me']), - unfollowModal: state.getIn(['meta', 'unfollow_modal']), }); return mapStateToProps; @@ -39,7 +37,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { - if (this.unfollowModal) { + if (unfollowModal) { dispatch(openModal('CONFIRM', { message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, confirm: intl.formatMessage(messages.unfollowConfirm), @@ -77,11 +75,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (account.getIn(['relationship', 'muting'])) { dispatch(unmuteAccount(account.get('id'))); } else { - dispatch(openModal('CONFIRM', { - message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, - confirm: intl.formatMessage(messages.muteConfirm), - onConfirm: () => dispatch(muteAccount(account.get('id'))), - })); + dispatch(initMuteModal(account)); } }, diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index fe92216d5..f8c85c296 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -16,7 +16,6 @@ const mapStateToProps = (state, props) => ({ statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), - me: state.getIn(['meta', 'me']), }); @connect(mapStateToProps) @@ -28,7 +27,6 @@ export default class AccountTimeline extends ImmutablePureComponent { statusIds: ImmutablePropTypes.list, isLoading: PropTypes.bool, hasMore: PropTypes.bool, - me: PropTypes.string.isRequired, }; componentWillMount () { @@ -50,7 +48,7 @@ export default class AccountTimeline extends ImmutablePureComponent { } render () { - const { statusIds, isLoading, hasMore, me } = this.props; + const { statusIds, isLoading, hasMore } = this.props; if (!statusIds && isLoading) { return ( @@ -70,7 +68,6 @@ export default class AccountTimeline extends ImmutablePureComponent { statusIds={statusIds} isLoading={isLoading} hasMore={hasMore} - me={me} onScrollToBottom={this.handleScrollToBottom} /> </Column> diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index b16af4b28..14a512ae8 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import LoadingIndicator from '../../components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import AccountContainer from '../../containers/account_container'; diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 7d175a912..7890755f3 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -41,7 +41,6 @@ export default class ComposeForm extends ImmutablePureComponent { preselectDate: PropTypes.instanceOf(Date), is_submitting: PropTypes.bool, is_uploading: PropTypes.bool, - me: PropTypes.string, onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, onClearSuggestions: PropTypes.func.isRequired, 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 dffa04ff0..dc8fc02ba 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -157,7 +157,6 @@ class EmojiPickerMenu extends React.PureComponent { intl: PropTypes.object.isRequired, skinTone: PropTypes.number.isRequired, onSkinTone: PropTypes.func.isRequired, - autoPlay: PropTypes.bool, }; static defaultProps = { @@ -235,7 +234,7 @@ class EmojiPickerMenu extends React.PureComponent { } render () { - const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props; + const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; if (loading) { return <div style={{ width: 299 }} />; @@ -250,7 +249,7 @@ class EmojiPickerMenu extends React.PureComponent { perLine={8} emojiSize={22} sheetSize={32} - custom={buildCustomEmojis(custom_emojis, autoPlay)} + custom={buildCustomEmojis(custom_emojis)} color='' emoji='' set='twitter' @@ -284,7 +283,6 @@ 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, @@ -346,7 +344,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { } render () { - const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; const title = intl.formatMessage(messages.emoji); const { active, loading } = this.state; @@ -366,7 +364,6 @@ export default class EmojiPickerDropdown extends React.PureComponent { loading={loading} onClose={this.onHideDropdown} onPick={onPickEmoji} - autoPlay={autoPlay} onSkinTone={onSkinTone} skinTone={skinTone} frequentlyUsedEmojis={frequentlyUsedEmojis} diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index 5d8d66cf7..6ab76492a 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -68,7 +68,7 @@ export default class Upload extends ImmutablePureComponent { <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')})` }}> + <div className='compose-form__upload-thumbnail' style={{ transform: `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 })}> diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 12d435ded..5f5509dbe 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -22,7 +22,6 @@ const mapStateToProps = state => ({ preselectDate: state.getIn(['compose', 'preselectDate']), is_submitting: state.getIn(['compose', 'is_submitting']), is_uploading: state.getIn(['compose', 'is_uploading']), - me: state.getIn(['compose', 'me']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), }); 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 71944128c..e6a535a5d 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 @@ -46,7 +46,7 @@ const getFrequentlyUsedEmojis = createSelector([ const getCustomEmojis = createSelector([ state => state.get('custom_emojis'), -], emojis => emojis.sort((a, b) => { +], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { const aShort = a.get('shortcode').toLowerCase(); const bShort = b.get('shortcode').toLowerCase(); @@ -61,7 +61,6 @@ const getCustomEmojis = createSelector([ const mapStateToProps = state => ({ custom_emojis: getCustomEmojis(state), - autoPlay: state.getIn(['meta', 'auto_play_gif']), skinTone: state.getIn(['settings', 'skinTone']), frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), }); diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js index 8cc53c087..eb9f3ea45 100644 --- a/app/javascript/mastodon/features/compose/containers/navigation_container.js +++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js @@ -1,9 +1,10 @@ import { connect } from 'react-redux'; import NavigationBar from '../components/navigation_bar'; +import { me } from '../../../initial_state'; const mapStateToProps = state => { return { - account: state.getIn(['accounts', state.getIn(['meta', 'me'])]), + account: state.getIn(['accounts', me]), }; }; diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js index e4bd5a743..c8e74f5a1 100644 --- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -47,7 +47,7 @@ class SensitiveButton extends React.PureComponent { 'compose-form__sensitive-button--visible': visible, }); return ( - <div className={className} style={{ transform: `translateZ(0) scale(${scale})` }}> + <div className={className} style={{ transform: `scale(${scale})` }}> <IconButton className='compose-form__sensitive-button__icon' title={intl.formatMessage(messages.title)} diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js index 35eab5976..d34471a3e 100644 --- a/app/javascript/mastodon/features/compose/containers/warning_container.js +++ b/app/javascript/mastodon/features/compose/containers/warning_container.js @@ -3,9 +3,10 @@ import { connect } from 'react-redux'; import Warning from '../components/warning'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import { me } from '../../../initial_state'; const mapStateToProps = state => ({ - needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']), + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), }); const WarningWrapper = ({ needsLockWarning }) => { diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js index e6d2487c5..700ba2163 100644 --- a/app/javascript/mastodon/features/compose/util/counter.js +++ b/app/javascript/mastodon/features/compose/util/counter.js @@ -5,5 +5,5 @@ const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx'; export function countableText(inputText) { return inputText .replace(urlRegex, urlPlaceholder) - .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '@$2'); + .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3'); }; diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js index 636402172..372459c78 100644 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -57,5 +57,21 @@ describe('emoji', () => { it('does an emoji whose filename is irregular', () => { expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />'); }); + + it('avoid emojifying on invisible text', () => { + expect(emojify('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>')) + .toEqual('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>'); + expect(emojify('<span class="invisible">:luigi:</span>', { ':luigi:': { static_url: 'luigi.exe' } })) + .toEqual('<span class="invisible">:luigi:</span>'); + }); + + it('avoid emojifying on invisible text with nested tags', () => { + expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇')) + .toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />'); + expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇')) + .toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />'); + expect(emojify('<span class="invisible">😄<br/>😴</span>😇')) + .toEqual('<span class="invisible">😄<br/>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />'); + }); }); }); diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index b70fc2b37..0f005dd50 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -1,3 +1,4 @@ +import { autoPlayGif } from '../../initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; import Trie from 'substring-trie'; @@ -5,13 +6,13 @@ const trie = new Trie(Object.keys(unicodeMapping)); const assetHost = process.env.CDN_HOST || ''; -let allowAnimations = false; - const emojify = (str, customEmojis = {}) => { - let rtn = ''; + const tagCharsWithoutEmojis = '<&'; + const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&'; + let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0; for (;;) { let match, i = 0, tag; - while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) { + while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) { i += str.codePointAt(i) < 65536 ? 1 : 2; } let rend, replacement = ''; @@ -27,7 +28,7 @@ const emojify = (str, customEmojis = {}) => { // 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; + const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url; replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`; return true; } @@ -35,7 +36,26 @@ const emojify = (str, customEmojis = {}) => { })()) rend = ++i; } else if (tag >= 0) { // <, & rend = str.indexOf('>;'[tag], i + 1) + 1; - if (!rend) break; + if (!rend) { + break; + } + if (tag === 0) { + if (invisible) { + if (str[i + 1] === '/') { // closing tag + if (!--invisible) { + tagChars = tagCharsWithEmojis; + } + } else if (str[rend - 2] !== '/') { // opening tag + invisible++; + } + } else { + if (str.startsWith('<span class="invisible">', i)) { + // avoid emojifying on invisible text + invisible = 1; + tagChars = tagCharsWithoutEmojis; + } + } + } i = rend; } else { // matched to unicode emoji const { filename, shortCode } = unicodeMapping[match]; @@ -51,14 +71,12 @@ const emojify = (str, customEmojis = {}) => { export default emojify; -export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => { +export const buildCustomEmojis = (customEmojis) => { const emojis = []; - allowAnimations = overrideAllowAnimations; - customEmojis.forEach(emoji => { const shortcode = emoji.get('shortcode'); - const url = allowAnimations ? emoji.get('url') : emoji.get('static_url'); + const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url'); const name = shortcode.replace(':', ''); emojis.push({ diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js index c0cba952a..e5b834a74 100644 --- a/app/javascript/mastodon/features/emoji/emoji_compressed.js +++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js @@ -64,14 +64,14 @@ Object.keys(emojiMap).forEach(key => { Object.keys(emojiIndex.emojis).forEach(key => { const { native } = emojiIndex.emojis[key]; - const { short_names, search, unified } = emojiMartData.emojis[key]; + let { 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 + short_names = short_names.slice(1); // first short name can be inferred from the key const searchData = [native, short_names, search]; if (unicodeToUnifiedName(native) !== unified) { diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js index 4dbfefd87..6f113beb4 100644 --- a/app/javascript/mastodon/features/favourites/index.js +++ b/app/javascript/mastodon/features/favourites/index.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchFavourites } from '../../actions/interactions'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js index 4c9e514cb..eae821f92 100644 --- a/app/javascript/mastodon/features/follow_requests/index.js +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import AccountAuthorizeContainer from './containers/account_authorize_container'; diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index 89445559f..f64ed7948 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -8,7 +8,7 @@ import { fetchFollowers, expandFollowers, } from '../../actions/accounts'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import HeaderContainer from '../account_timeline/containers/header_container'; diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index c34830276..a0c0fac05 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -8,7 +8,7 @@ import { fetchFollowing, expandFollowing, } from '../../actions/accounts'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import HeaderContainer from '../account_timeline/containers/header_container'; diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 973c8a4ae..4b4ae6947 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -7,6 +7,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from '../../initial_state'; const messages = defineMessages({ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, @@ -27,7 +28,7 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ - me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), + myAccount: state.getIn(['accounts', me]), columns: state.getIn(['settings', 'columns']), }); @@ -37,13 +38,13 @@ export default class GettingStarted extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, - me: ImmutablePropTypes.map.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, columns: ImmutablePropTypes.list, multiColumn: PropTypes.bool, }; render () { - const { intl, me, columns, multiColumn } = this.props; + const { intl, myAccount, columns, multiColumn } = this.props; let navItems = []; @@ -70,7 +71,7 @@ export default class GettingStarted extends ImmutablePureComponent { <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, ]); - if (me.get('locked')) { + if (myAccount.get('locked')) { navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); } diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js index 25ca921ae..bb351ece2 100644 --- a/app/javascript/mastodon/features/mutes/index.js +++ b/app/javascript/mastodon/features/mutes/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import AccountContainer from '../../containers/account_container'; diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js index f1904786a..579d6aaa0 100644 --- a/app/javascript/mastodon/features/reblogs/index.js +++ b/app/javascript/mastodon/features/reblogs/index.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchReblogs } from '../../actions/interactions'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 034cc9854..7b65420d0 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -4,6 +4,7 @@ import IconButton from '../../../components/icon_button'; import ImmutablePropTypes from 'react-immutable-proptypes'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; +import { me } from '../../../initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -36,7 +37,6 @@ export default class ActionBar extends React.PureComponent { onReport: PropTypes.func, onPin: PropTypes.func, onEmbed: PropTypes.func, - me: PropTypes.string.isRequired, intl: PropTypes.object.isRequired, }; @@ -80,7 +80,7 @@ export default class ActionBar extends React.PureComponent { } render () { - const { status, me, intl } = this.props; + const { status, intl } = this.props; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index c10e2c531..81f71749b 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -22,7 +22,6 @@ export default class DetailedStatus extends ImmutablePureComponent { status: ImmutablePropTypes.map.isRequired, onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, - autoPlayGif: PropTypes.bool, }; handleAccountClick = (e) => { @@ -70,7 +69,6 @@ export default class DetailedStatus extends ImmutablePureComponent { media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} - autoPlayGif={this.props.autoPlayGif} /> ); } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 7ad3a7644..cc28ff5fc 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -1,6 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { fetchStatus } from '../../actions/statuses'; import MissingIndicator from '../../components/missing_indicator'; @@ -22,13 +23,15 @@ import { import { deleteStatus } from '../../actions/statuses'; import { initReport } from '../../actions/reports'; import { makeGetStatus } from '../../selectors'; -import { ScrollContainer } from 'react-router-scroll'; +import { ScrollContainer } from 'react-router-scroll-4'; import ColumnBackButton from '../../components/column_back_button'; import StatusContainer from '../../containers/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'; +import { boostModal, deleteModal } from '../../initial_state'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -42,10 +45,6 @@ const makeMapStateToProps = () => { status: getStatus(state, props.params.statusId), ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]), descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]), - me: state.getIn(['meta', 'me']), - boostModal: state.getIn(['meta', 'boost_modal']), - deleteModal: state.getIn(['meta', 'delete_modal']), - autoPlayGif: state.getIn(['meta', 'auto_play_gif']), }); return mapStateToProps; @@ -65,17 +64,21 @@ export default class Status extends ImmutablePureComponent { status: ImmutablePropTypes.map, ancestorsIds: ImmutablePropTypes.list, descendantsIds: ImmutablePropTypes.list, - me: PropTypes.string, - boostModal: PropTypes.bool, - deleteModal: PropTypes.bool, - autoPlayGif: PropTypes.bool, intl: PropTypes.object.isRequired, }; + state = { + fullscreen: false, + }; + componentWillMount () { this.props.dispatch(fetchStatus(this.props.params.statusId)); } + componentDidMount () { + attachFullscreenListener(this.onFullScreenChange); + } + componentWillReceiveProps (nextProps) { if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { this._scrolledIntoView = false; @@ -111,7 +114,7 @@ export default class Status extends ImmutablePureComponent { if (status.get('reblogged')) { this.props.dispatch(unreblog(status)); } else { - if (e.shiftKey || !this.props.boostModal) { + if (e.shiftKey || !boostModal) { this.handleModalReblog(status); } else { this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); @@ -122,7 +125,7 @@ export default class Status extends ImmutablePureComponent { handleDeleteClick = (status) => { const { dispatch, intl } = this.props; - if (!this.props.deleteModal) { + if (!deleteModal) { dispatch(deleteStatus(status.get('id'))); } else { dispatch(openModal('CONFIRM', { @@ -255,9 +258,18 @@ export default class Status extends ImmutablePureComponent { } } + componentWillUnmount () { + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + render () { let ancestors, descendants; - const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props; + const { status, ancestorsIds, descendantsIds } = this.props; + const { fullscreen } = this.state; if (status === null) { return ( @@ -291,22 +303,19 @@ export default class Status extends ImmutablePureComponent { <ColumnBackButton /> <ScrollContainer scrollKey='thread'> - <div className='scrollable detailed-status__wrapper' ref={this.setRef}> + <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}> {ancestors} <HotKeys handlers={handlers}> <div className='focusable' tabIndex='0'> <DetailedStatus status={status} - autoPlayGif={autoPlayGif} - me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} /> <ActionBar status={status} - me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index f420f0abf..79d86370e 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -10,6 +10,7 @@ import BoostModal from './boost_modal'; import ConfirmationModal from './confirmation_modal'; import { OnboardingModal, + MuteModal, ReportModal, EmbedModal, } from '../../../features/ui/util/async-components'; @@ -20,6 +21,7 @@ const MODAL_COMPONENTS = { 'VIDEO': () => Promise.resolve({ default: VideoModal }), 'BOOST': () => Promise.resolve({ default: BoostModal }), 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), + 'MUTE': MuteModal, 'REPORT': ReportModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js new file mode 100644 index 000000000..73e48cf09 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/mute_modal.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Button from '../../../components/button'; +import { closeModal } from '../../../actions/modal'; +import { muteAccount } from '../../../actions/accounts'; +import { toggleHideNotifications } from '../../../actions/mutes'; + + +const mapStateToProps = state => { + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: state.getIn(['mutes', 'new', 'account']), + notifications: state.getIn(['mutes', 'new', 'notifications']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, + + onClose() { + dispatch(closeModal()); + }, + + onToggleNotifications() { + dispatch(toggleHideNotifications()); + }, + }; +}; + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class MuteModal extends React.PureComponent { + + static propTypes = { + isSubmitting: PropTypes.bool.isRequired, + account: PropTypes.object.isRequired, + notifications: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + onToggleNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account, this.props.notifications); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + toggleNotifications = () => { + this.props.onToggleNotifications(); + } + + render () { + const { account, notifications } = this.props; + + return ( + <div className='modal-root__modal mute-modal'> + <div className='mute-modal__container'> + <p> + <FormattedMessage + id='confirmations.mute.message' + defaultMessage='Are you sure you want to mute {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + <div> + <label htmlFor='mute-modal__hide-notifications-checkbox'> + <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> + {' '} + <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> + </label> + </div> + </div> + + <div className='mute-modal__action-bar'> + <Button onClick={this.handleCancel} className='mute-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleClick} ref={this.setRef}> + <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' /> + </Button> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index 7905bca2e..54673e223 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -11,6 +11,7 @@ import Search from '../../compose/components/search'; import NavigationBar from '../../compose/components/navigation_bar'; import ColumnHeader from './column_header'; import { List as ImmutableList } from 'immutable'; +import { me } from '../../../initial_state'; const noop = () => { }; @@ -40,11 +41,11 @@ PageOne.propTypes = { domain: PropTypes.string.isRequired, }; -const PageTwo = ({ me }) => ( +const PageTwo = ({ myAccount }) => ( <div className='onboarding-modal__page onboarding-modal__page-two'> <div className='figure non-interactive'> <div className='pseudo-drawer'> - <NavigationBar account={me} /> + <NavigationBar account={myAccount} /> </div> <ComposeForm text='Awoo! #introductions' @@ -68,10 +69,10 @@ const PageTwo = ({ me }) => ( ); PageTwo.propTypes = { - me: ImmutablePropTypes.map.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, }; -const PageThree = ({ me }) => ( +const PageThree = ({ myAccount }) => ( <div className='onboarding-modal__page onboarding-modal__page-three'> <div className='figure non-interactive'> <Search @@ -83,7 +84,7 @@ const PageThree = ({ me }) => ( /> <div className='pseudo-drawer'> - <NavigationBar account={me} /> + <NavigationBar account={myAccount} /> </div> </div> @@ -93,7 +94,7 @@ const PageThree = ({ me }) => ( ); PageThree.propTypes = { - me: ImmutablePropTypes.map.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, }; const PageFour = ({ domain, intl }) => ( @@ -161,7 +162,7 @@ PageSix.propTypes = { }; const mapStateToProps = state => ({ - me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), + myAccount: state.getIn(['accounts', me]), admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), domain: state.getIn(['meta', 'domain']), }); @@ -173,7 +174,7 @@ export default class OnboardingModal extends React.PureComponent { static propTypes = { onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, - me: ImmutablePropTypes.map.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, domain: PropTypes.string.isRequired, admin: ImmutablePropTypes.map, }; @@ -183,11 +184,11 @@ export default class OnboardingModal extends React.PureComponent { }; componentWillMount() { - const { me, admin, domain, intl } = this.props; + const { myAccount, admin, domain, intl } = this.props; this.pages = [ - <PageOne acct={me.get('acct')} domain={domain} />, - <PageTwo me={me} />, - <PageThree me={me} />, + <PageOne acct={myAccount.get('acct')} domain={domain} />, + <PageTwo myAccount={myAccount} />, + <PageThree myAccount={myAccount} />, <PageFour domain={domain} intl={intl} />, <PageSix admin={admin} domain={domain} />, ]; diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js index c19065be6..8b9a26270 100644 --- a/app/javascript/mastodon/features/ui/components/upload_area.js +++ b/app/javascript/mastodon/features/ui/components/upload_area.js @@ -40,7 +40,7 @@ export default class UploadArea extends React.PureComponent { {({ backgroundOpacity, backgroundScale }) => <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> <div className='upload-area__drop'> - <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} /> + <div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} /> <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> </div> </div> diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index ff29bfdd4..a0aec4403 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -4,13 +4,13 @@ import { scrollTopTimeline } from '../../../actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { debounce } from 'lodash'; +import { me } from '../../../initial_state'; const makeGetStatusIds = () => createSelector([ (state, { type }) => state.getIn(['settings', type], ImmutableMap()), (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), (state) => state.get('statuses'), - (state) => state.getIn(['meta', 'me']), -], (columnSettings, statusIds, statuses, me) => { +], (columnSettings, statusIds, statuses) => { const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); let regex = null; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 70e451373..f28b37099 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -38,14 +38,20 @@ import { PinnedStatuses, } from './util/async-components'; import { HotKeys } from 'react-hotkeys'; +import { me } from '../../initial_state'; +import { defineMessages, injectIntl } from 'react-intl'; // Dummy import, to make sure that <Status /> ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import '../../components/status'; +const messages = defineMessages({ + beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, +}); + const mapStateToProps = state => ({ - me: state.getIn(['meta', 'me']), isComposing: state.getIn(['compose', 'is_composing']), + hasComposingText: state.getIn(['compose', 'text']) !== '', }); const keyMap = { @@ -75,6 +81,7 @@ const keyMap = { }; @connect(mapStateToProps) +@injectIntl @withRouter export default class UI extends React.Component { @@ -86,8 +93,9 @@ export default class UI extends React.Component { dispatch: PropTypes.func.isRequired, children: PropTypes.node, isComposing: PropTypes.bool, - me: PropTypes.string, + hasComposingText: PropTypes.bool, location: PropTypes.object, + intl: PropTypes.object.isRequired, }; state = { @@ -95,6 +103,17 @@ export default class UI extends React.Component { draggingOver: false, }; + handleBeforeUnload = (e) => { + const { intl, isComposing, hasComposingText } = this.props; + + if (isComposing && hasComposingText) { + // Setting returnValue to any string causes confirmation dialog. + // Many browsers no longer display this text to users, + // but we set user-friendly message for other browsers, e.g. Edge. + e.returnValue = intl.formatMessage(messages.beforeUnload); + } + } + handleResize = debounce(() => { // The cached heights are no longer accurate, invalidate this.props.dispatch(clearHeight()); @@ -169,6 +188,7 @@ export default class UI extends React.Component { } componentWillMount () { + window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('resize', this.handleResize, { passive: true }); document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragover', this.handleDragOver, false); @@ -210,6 +230,7 @@ export default class UI extends React.Component { } componentWillUnmount () { + window.removeEventListener('beforeunload', this.handleBeforeUnload); window.removeEventListener('resize', this.handleResize); document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragover', this.handleDragOver); @@ -305,7 +326,7 @@ export default class UI extends React.Component { } handleHotkeyGoToProfile = () => { - this.context.router.history.push(`/accounts/${this.props.me}`); + this.context.router.history.push(`/accounts/${me}`); } handleHotkeyGoToBlocked = () => { diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 8f7b91d21..39663d5ca 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -86,6 +86,10 @@ export function OnboardingModal () { return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'); } +export function MuteModal () { + return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal'); +} + export function ReportModal () { return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); } diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js index af6368738..df3a8b54a 100644 --- a/app/javascript/mastodon/features/ui/util/optional_motion.js +++ b/app/javascript/mastodon/features/ui/util/optional_motion.js @@ -1,56 +1,5 @@ -// Like react-motion's Motion, but checks to see if the user prefers -// reduced motion and uses a cross-fade in those cases. - -import React from 'react'; +import { reduceMotion } from '../../../initial_state'; +import ReducedMotion from './reduced_motion'; import Motion from 'react-motion/lib/Motion'; -import PropTypes from 'prop-types'; - -const stylesToKeep = ['opacity', 'backgroundOpacity']; - -let reduceMotion; - -const extractValue = (value) => { - // This is either an object with a "val" property or it's a number - return (typeof value === 'object' && value && 'val' in value) ? value.val : value; -}; - -class OptionalMotion extends React.Component { - - static propTypes = { - defaultStyle: PropTypes.object, - style: PropTypes.object, - children: PropTypes.func, - } - - render() { - - const { style, defaultStyle, children } = this.props; - - if (typeof reduceMotion !== 'boolean') { - // This never changes without a page reload, so we can just grab it - // once from the body classes as opposed to using Redux's connect(), - // which would unnecessarily update every state change - reduceMotion = document.body.classList.contains('reduce-motion'); - } - if (reduceMotion) { - Object.keys(style).forEach(key => { - if (stylesToKeep.includes(key)) { - return; - } - // If it's setting an x or height or scale or some other value, we need - // to preserve the end-state value without actually animating it - style[key] = defaultStyle[key] = extractValue(style[key]); - }); - } - - return ( - <Motion style={style} defaultStyle={defaultStyle}> - {children} - </Motion> - ); - } - -} - -export default OptionalMotion; +export default reduceMotion ? ReducedMotion : Motion; 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 86b30d488..43007ddc3 100644 --- a/app/javascript/mastodon/features/ui/util/react_router_helpers.js +++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.js @@ -7,11 +7,19 @@ import BundleColumnError from '../components/bundle_column_error'; import BundleContainer from '../containers/bundle_container'; // Small wrapper to pass multiColumn to the route components -export const WrappedSwitch = ({ multiColumn, children }) => ( - <Switch> - {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} - </Switch> -); +export class WrappedSwitch extends React.PureComponent { + + render () { + const { multiColumn, children } = this.props; + + return ( + <Switch> + {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} + </Switch> + ); + } + +} WrappedSwitch.propTypes = { multiColumn: PropTypes.bool, diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.js b/app/javascript/mastodon/features/ui/util/reduced_motion.js new file mode 100644 index 000000000..95519042b --- /dev/null +++ b/app/javascript/mastodon/features/ui/util/reduced_motion.js @@ -0,0 +1,44 @@ +// Like react-motion's Motion, but reduces all animations to cross-fades +// for the benefit of users with motion sickness. +import React from 'react'; +import Motion from 'react-motion/lib/Motion'; +import PropTypes from 'prop-types'; + +const stylesToKeep = ['opacity', 'backgroundOpacity']; + +const extractValue = (value) => { + // This is either an object with a "val" property or it's a number + return (typeof value === 'object' && value && 'val' in value) ? value.val : value; +}; + +class ReducedMotion extends React.Component { + + static propTypes = { + defaultStyle: PropTypes.object, + style: PropTypes.object, + children: PropTypes.func, + } + + render() { + + const { style, defaultStyle, children } = this.props; + + Object.keys(style).forEach(key => { + if (stylesToKeep.includes(key)) { + return; + } + // If it's setting an x or height or scale or some other value, we need + // to preserve the end-state value without actually animating it + style[key] = defaultStyle[key] = extractValue(style[key]); + }); + + return ( + <Motion style={style} defaultStyle={defaultStyle}> + {children} + </Motion> + ); + } + +} + +export default ReducedMotion; diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js new file mode 100644 index 000000000..3fc45077d --- /dev/null +++ b/app/javascript/mastodon/initial_state.js @@ -0,0 +1,13 @@ +const element = document.getElementById('initial-state'); +const initialState = element && JSON.parse(element.textContent); + +const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop]; + +export const reduceMotion = getMeta('reduce_motion'); +export const autoPlayGif = getMeta('auto_play_gif'); +export const unfollowModal = getMeta('unfollow_modal'); +export const boostModal = getMeta('boost_modal'); +export const deleteModal = getMeta('delete_modal'); +export const me = getMeta('me'); + +export default initialState; diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 22639f6f9..3f67a8fff 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -1,221 +1,221 @@ { "account.block": "Bloki @{name}", - "account.block_domain": "Hide everything from {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.block_domain": "Kaŝi ĉion el {domain}", + "account.disclaimer_full": "La ĉi-subaj informoj povas ne plene reflekti la profilon de la uzanto.", "account.edit_profile": "Redakti la profilon", "account.follow": "Sekvi", "account.followers": "Sekvantoj", "account.follows": "Sekvatoj", "account.follows_you": "Sekvas vin", - "account.media": "Media", + "account.media": "Sonbildaĵoj", "account.mention": "Mencii @{name}", - "account.mute": "Mute @{name}", + "account.mute": "Silentigi @{name}", "account.posts": "Mesaĝoj", - "account.report": "Report @{name}", + "account.report": "Signali @{name}", "account.requested": "Atendas aprobon", - "account.share": "Share @{name}'s profile", + "account.share": "Diskonigi la profilon de @{name}", "account.unblock": "Malbloki @{name}", - "account.unblock_domain": "Unhide {domain}", - "account.unfollow": "Malsekvi", - "account.unmute": "Unmute @{name}", - "account.view_full_profile": "View full profile", - "boost_modal.combo": "You can press {combo} to skip this next time", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", - "column.blocks": "Blocked users", + "account.unblock_domain": "Malkaŝi {domain}", + "account.unfollow": "Ne plus sekvi", + "account.unmute": "Malsilentigi @{name}", + "account.view_full_profile": "Vidi plenan profilon", + "boost_modal.combo": "La proksiman fojon, premu {combo} por pasigi", + "bundle_column_error.body": "Io malfunkciis ŝargante tiun ĉi komponanton.", + "bundle_column_error.retry": "Bonvolu reprovi", + "bundle_column_error.title": "Reta eraro", + "bundle_modal_error.close": "Fermi", + "bundle_modal_error.message": "Io malfunkciis ŝargante tiun ĉi komponanton.", + "bundle_modal_error.retry": "Bonvolu reprovi", + "column.blocks": "Blokitaj uzantoj", "column.community": "Loka tempolinio", - "column.favourites": "Favourites", - "column.follow_requests": "Follow requests", + "column.favourites": "Favoritoj", + "column.follow_requests": "Abonpetoj", "column.home": "Hejmo", - "column.mutes": "Muted users", + "column.mutes": "Silentigitaj uzantoj", "column.notifications": "Sciigoj", - "column.pins": "Pinned toot", + "column.pins": "Alpinglitaj pepoj", "column.public": "Fratara tempolinio", "column_back_button.label": "Reveni", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", - "column_header.pin": "Pin", - "column_header.show_settings": "Show settings", - "column_header.unpin": "Unpin", - "column_subheading.navigation": "Navigation", - "column_subheading.settings": "Settings", - "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", - "compose_form.lock_disclaimer.lock": "locked", + "column_header.hide_settings": "Kaŝi agordojn", + "column_header.moveLeft_settings": "Movi kolumnon maldekstren", + "column_header.moveRight_settings": "Movi kolumnon dekstren", + "column_header.pin": "Alpingli", + "column_header.show_settings": "Malkaŝi agordojn", + "column_header.unpin": "Depingli", + "column_subheading.navigation": "Navigado", + "column_subheading.settings": "Agordoj", + "compose_form.lock_disclaimer": "Via konta ne estas ŝlosita. Iu ajn povas sekvi vin por vidi viajn privatajn pepojn.", + "compose_form.lock_disclaimer.lock": "ŝlosita", "compose_form.placeholder": "Pri kio vi pensas?", "compose_form.publish": "Hup", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive": "Marki ke la enhavo estas tikla", "compose_form.spoiler": "Kaŝi la tekston malantaŭ averto", - "compose_form.spoiler_placeholder": "Content warning", - "confirmation_modal.cancel": "Cancel", - "confirmations.block.confirm": "Block", - "confirmations.block.message": "Are you sure you want to block {name}?", - "confirmations.delete.confirm": "Delete", - "confirmations.delete.message": "Are you sure you want to delete this status?", - "confirmations.domain_block.confirm": "Hide entire domain", - "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", - "confirmations.mute.confirm": "Mute", - "confirmations.mute.message": "Are you sure you want to mute {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", - "embed.instructions": "Embed this status on your website by copying the code below.", - "embed.preview": "Here is what it will look like:", - "emoji_button.activity": "Activity", - "emoji_button.custom": "Custom", - "emoji_button.flags": "Flags", - "emoji_button.food": "Food & Drink", - "emoji_button.label": "Insert emoji", - "emoji_button.nature": "Nature", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", - "emoji_button.objects": "Objects", - "emoji_button.people": "People", - "emoji_button.recent": "Frequently used", - "emoji_button.search": "Search...", - "emoji_button.search_results": "Search results", - "emoji_button.symbols": "Symbols", - "emoji_button.travel": "Travel & Places", - "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", - "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", - "empty_column.home.public_timeline": "the public timeline", - "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", - "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", - "follow_request.authorize": "Authorize", - "follow_request.reject": "Reject", - "getting_started.appsshort": "Apps", - "getting_started.faq": "FAQ", + "compose_form.spoiler_placeholder": "Skribu tie vian averton", + "confirmation_modal.cancel": "Malfari", + "confirmations.block.confirm": "Bloki", + "confirmations.block.message": "Ĉu vi konfirmas la blokadon de {name}?", + "confirmations.delete.confirm": "Malaperigi", + "confirmations.delete.message": "Ĉu vi konfirmas la malaperigon de tiun pepon?", + "confirmations.domain_block.confirm": "Kaŝi la tutan reton", + "confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas bloki {domain} tute? Plej ofte, kelkaj celitaj blokadoj aŭ silentigoj estas sufiĉaj kaj preferindaj.", + "confirmations.mute.confirm": "Silentigi", + "confirmations.mute.message": "Ĉu vi konfirmas la silentigon de {name}?", + "confirmations.unfollow.confirm": "Ne plu sekvi", + "confirmations.unfollow.message": "Ĉu vi volas ĉesi sekvi {name}?", + "embed.instructions": "Enmetu tiun statkonigon ĉe vian retejon kopiante la ĉi-suban kodon.", + "embed.preview": "Ĝi aperos tiel:", + "emoji_button.activity": "Aktivecoj", + "emoji_button.custom": "Personaj", + "emoji_button.flags": "Flagoj", + "emoji_button.food": "Manĝi kaj trinki", + "emoji_button.label": "Enmeti mieneton", + "emoji_button.nature": "Naturo", + "emoji_button.not_found": "Neniuj mienetoj!! (╯°□°)╯︵ ┻━┻", + "emoji_button.objects": "Objektoj", + "emoji_button.people": "Homoj", + "emoji_button.recent": "Ofte uzataj", + "emoji_button.search": "Serĉo…", + "emoji_button.search_results": "Rezultatoj de serĉo", + "emoji_button.symbols": "Simboloj", + "emoji_button.travel": "Vojaĝoj & lokoj", + "empty_column.community": "La loka tempolinio estas malplena. Skribu ion por plenigi ĝin!", + "empty_column.hashtag": "Ĝise, neniu enhavo estas asociita kun tiu kradvorto.", + "empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.", + "empty_column.home.public_timeline": "la publika tempolinio", + "empty_column.notifications": "Vi dume ne havas sciigojn. Interagi kun aliajn uzantojn por komenci la konversacion.", + "empty_column.public": "Estas nenio ĉi tie! Publike skribu ion, aŭ mane sekvu uzantojn de aliaj instancoj por plenigi la publikan tempolinion.", + "follow_request.authorize": "Akcepti", + "follow_request.reject": "Rifuzi", + "getting_started.appsshort": "Aplikaĵoj", + "getting_started.faq": "Oftaj demandoj", "getting_started.heading": "Por komenci", - "getting_started.open_source_notice": "Mastodon estas malfermitkoda programo. Vi povas kontribui aŭ raporti problemojn en github je {github}.", - "getting_started.userguide": "User Guide", - "home.column_settings.advanced": "Advanced", - "home.column_settings.basic": "Basic", - "home.column_settings.filter_regex": "Filter out by regular expressions", - "home.column_settings.show_reblogs": "Show boosts", - "home.column_settings.show_replies": "Show replies", - "home.settings": "Column settings", + "getting_started.open_source_notice": "Mastodono estas malfermkoda programo. Vi povas kontribui aŭ raporti problemojn en GitHub je {github}.", + "getting_started.userguide": "Gvidilo de uzo", + "home.column_settings.advanced": "Precizaj agordoj", + "home.column_settings.basic": "Bazaj agordoj", + "home.column_settings.filter_regex": "Forfiltri per regulesprimo", + "home.column_settings.show_reblogs": "Montri diskonigojn", + "home.column_settings.show_replies": "Montri respondojn", + "home.settings": "Agordoj de la kolumno", "lightbox.close": "Fermi", - "lightbox.next": "Next", - "lightbox.previous": "Previous", - "loading_indicator.label": "Ŝarĝanta...", - "media_gallery.toggle_visible": "Toggle visibility", - "missing_indicator.label": "Not found", - "navigation_bar.blocks": "Blocked users", + "lightbox.next": "Malantaŭa", + "lightbox.previous": "Antaŭa", + "loading_indicator.label": "Ŝarganta…", + "media_gallery.toggle_visible": "Baskuli videblecon", + "missing_indicator.label": "Ne trovita", + "navigation_bar.blocks": "Blokitaj uzantoj", "navigation_bar.community_timeline": "Loka tempolinio", "navigation_bar.edit_profile": "Redakti la profilon", - "navigation_bar.favourites": "Favourites", - "navigation_bar.follow_requests": "Follow requests", - "navigation_bar.info": "Extended information", + "navigation_bar.favourites": "Favoritaj", + "navigation_bar.follow_requests": "Abonpetoj", + "navigation_bar.info": "Plia informo", "navigation_bar.logout": "Elsaluti", - "navigation_bar.mutes": "Muted users", - "navigation_bar.pins": "Pinned toots", + "navigation_bar.mutes": "Silentigitaj uzantoj", + "navigation_bar.pins": "Alpinglitaj pepoj", "navigation_bar.preferences": "Preferoj", "navigation_bar.public_timeline": "Fratara tempolinio", "notification.favourite": "{name} favoris vian mesaĝon", "notification.follow": "{name} sekvis vin", "notification.mention": "{name} menciis vin", "notification.reblog": "{name} diskonigis vian mesaĝon", - "notifications.clear": "Clear notifications", - "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.clear": "Forviŝi la sciigojn", + "notifications.clear_confirmation": "Ĉu vi certe volas malaperigi ĉiujn viajn sciigojn?", "notifications.column_settings.alert": "Retumilaj atentigoj", - "notifications.column_settings.favourite": "Favoroj:", + "notifications.column_settings.favourite": "Favoritoj:", "notifications.column_settings.follow": "Novaj sekvantoj:", "notifications.column_settings.mention": "Mencioj:", - "notifications.column_settings.push": "Push notifications", - "notifications.column_settings.push_meta": "This device", + "notifications.column_settings.push": "Puŝsciigoj", + "notifications.column_settings.push_meta": "Tiu ĉi aparato", "notifications.column_settings.reblog": "Diskonigoj:", "notifications.column_settings.show": "Montri en kolono", - "notifications.column_settings.sound": "Play sound", - "onboarding.done": "Done", - "onboarding.next": "Next", - "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", - "onboarding.page_four.home": "The home timeline shows posts from people you follow.", - "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", - "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", - "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", - "onboarding.page_one.welcome": "Welcome to Mastodon!", - "onboarding.page_six.admin": "Your instance's admin is {admin}.", - "onboarding.page_six.almost_done": "Almost done...", - "onboarding.page_six.appetoot": "Bon Appetoot!", - "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", - "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", - "onboarding.page_six.guidelines": "community guidelines", - "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", - "onboarding.page_six.various_app": "mobile apps", - "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", - "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", - "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", - "onboarding.skip": "Skip", - "privacy.change": "Adjust status privacy", - "privacy.direct.long": "Post to mentioned users only", - "privacy.direct.short": "Direct", - "privacy.private.long": "Post to followers only", - "privacy.private.short": "Followers-only", - "privacy.public.long": "Post to public timelines", - "privacy.public.short": "Public", - "privacy.unlisted.long": "Do not show in public timelines", - "privacy.unlisted.short": "Unlisted", - "relative_time.days": "{number}d", + "notifications.column_settings.sound": "Eligi sonon", + "onboarding.done": "Farita", + "onboarding.next": "Malantaŭa", + "onboarding.page_five.public_timelines": "La loka tempolinio enhavas mesaĝojn de ĉiuj ĉe {domain}. La federacia tempolinio enhavas ĉiujn mesaĝojn de uzantoj, kiujn iu ĉe {domain} sekvas. Ambaŭ tre utilas por trovi novajn kunparolantojn.", + "onboarding.page_four.home": "La hejma tempolinio enhavas la mesaĝojn de ĉiuj uzantoj, kiuj vi sekvas.", + "onboarding.page_four.notifications": "La sciiga kolumno informas vin kiam iu interagas kun vi.", + "onboarding.page_one.federation": "Mastodono estas reto de nedependaj serviloj, unuiĝintaj por krei pligrandan socian retejon. Ni nomas tiujn servilojn instancoj.", + "onboarding.page_one.handle": "Vi estas ĉe {domain}, unu el la multaj instancoj de Mastodono. Via kompleta uznomo do estas {handle}", + "onboarding.page_one.welcome": "Bonvenon al Mastodono!", + "onboarding.page_six.admin": "Via instancestro estas {admin}.", + "onboarding.page_six.almost_done": "Estas preskaŭ finita…", + "onboarding.page_six.appetoot": "Bonan a‘pepi’ton!", + "onboarding.page_six.apps_available": "{apps} estas elŝuteblaj por iOS, Androido kaj alioj. Kaj nun… bonan a‘pepi’ton!", + "onboarding.page_six.github": "Mastodono estas libera, senpaga kaj malfermkoda programaro. Vi povas signali cimojn, proponi funkciojn aŭ kontribui al gîa kreskado ĉe {github}.", + "onboarding.page_six.guidelines": "komunreguloj", + "onboarding.page_six.read_guidelines": "Ni petas vin: ne forgesu legi la {guidelines}n de {domain}!", + "onboarding.page_six.various_app": "telefon-aplikaĵoj", + "onboarding.page_three.profile": "Redaktu vian profilon por ŝanĝi vian avataron, priskribon kaj vian nomon. Vi tie trovos ankoraŭ aliajn agordojn.", + "onboarding.page_three.search": "Uzu la serĉokampo por trovi uzantojn kaj esplori kradvortojn tiel ke {illustration} kaj {introductions}. Por trovi iun, kiu ne estas ĉe ĉi tiu instanco, uzu ĝian kompletan uznomon.", + "onboarding.page_two.compose": "Skribu pepojn en la verkkolumno. Vi povas aldoni bildojn, ŝanĝi la agordojn de privateco kaj aldoni tiklavertojn (« content warning ») dank' al la piktogramoj malsupre.", + "onboarding.skip": "Pasigi", + "privacy.change": "Alĝustigi la privateco de la mesaĝo", + "privacy.direct.long": "Vidigi nur al la menciitaj personoj", + "privacy.direct.short": "Rekta", + "privacy.private.long": "Vidigi nur al viaj sekvantoj", + "privacy.private.short": "Nursekvanta", + "privacy.public.long": "Vidigi en publikaj tempolinioj", + "privacy.public.short": "Publika", + "privacy.unlisted.long": "Ne vidigi en publikaj tempolinioj", + "privacy.unlisted.short": "Nelistigita", + "relative_time.days": "{number}t", "relative_time.hours": "{number}h", - "relative_time.just_now": "now", + "relative_time.just_now": "nun", "relative_time.minutes": "{number}m", "relative_time.seconds": "{number}s", - "reply_indicator.cancel": "Rezigni", - "report.placeholder": "Additional comments", - "report.submit": "Submit", - "report.target": "Reporting", + "reply_indicator.cancel": "Malfari", + "report.placeholder": "Pliaj komentoj", + "report.submit": "Sendi", + "report.target": "Signalaĵo", "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", + "search_popout.search_format": "Detala serĉo", + "search_popout.tips.hashtag": "kradvorto", + "search_popout.tips.status": "statkonigo", + "search_popout.tips.text": "Simpla teksto eligas la kongruajn afiŝnomojn, uznomojn kaj kradvortojn.", + "search_popout.tips.user": "uzanto", + "search_results.total": "{count, number} {count, plural, one {rezultato} other {rezultatoj}}", + "standalone.public_title": "Rigardeti…", + "status.cannot_reblog": "Tiun publikaĵon oni ne povas diskonigi", "status.delete": "Forigi", - "status.embed": "Embed", + "status.embed": "Enmeti", "status.favourite": "Favori", - "status.load_more": "Load more", - "status.media_hidden": "Media hidden", + "status.load_more": "Ŝargi plie", + "status.media_hidden": "Sonbildaĵo kaŝita", "status.mention": "Mencii @{name}", - "status.more": "More", - "status.mute_conversation": "Mute conversation", - "status.open": "Expand this status", - "status.pin": "Pin on profile", + "status.more": "Pli", + "status.mute_conversation": "Silentigi konversacion", + "status.open": "Disfaldi statkonigon", + "status.pin": "Pingli al la profilo", "status.reblog": "Diskonigi", - "status.reblogged_by": "{name} diskonigita", + "status.reblogged_by": "{name} diskonigis", "status.reply": "Respondi", - "status.replyAll": "Reply to thread", - "status.report": "Report @{name}", + "status.replyAll": "Respondi al la fadeno", + "status.report": "Signali @{name}", "status.sensitive_toggle": "Alklaki por vidi", "status.sensitive_warning": "Tikla enhavo", - "status.share": "Share", - "status.show_less": "Show less", - "status.show_more": "Show more", - "status.unmute_conversation": "Unmute conversation", - "status.unpin": "Unpin from profile", + "status.share": "Diskonigi", + "status.show_less": "Refaldi", + "status.show_more": "Disfaldi", + "status.unmute_conversation": "Malsilentigi konversacion", + "status.unpin": "Depingli de profilo", "tabs_bar.compose": "Ekskribi", - "tabs_bar.federated_timeline": "Federated", + "tabs_bar.federated_timeline": "Federacia tempolinio", "tabs_bar.home": "Hejmo", - "tabs_bar.local_timeline": "Local", + "tabs_bar.local_timeline": "Loka tempolinio", "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_area.title": "Algliti por alŝuti", + "upload_button.label": "Aldoni sonbildaĵon", + "upload_form.description": "Priskribi por la misvidantaj", "upload_form.undo": "Malfari", - "upload_progress.label": "Uploading...", - "video.close": "Close video", - "video.exit_fullscreen": "Exit full screen", - "video.expand": "Expand video", - "video.fullscreen": "Full screen", - "video.hide": "Hide video", - "video.mute": "Mute sound", - "video.pause": "Pause", - "video.play": "Play", - "video.unmute": "Unmute sound" + "upload_progress.label": "Alŝutanta…", + "video.close": "Fermi videon", + "video.exit_fullscreen": "Eliri el plenekrano", + "video.expand": "Vastigi videon", + "video.fullscreen": "Igi plenekrane", + "video.hide": "Kaŝi videon", + "video.mute": "Silentigi", + "video.pause": "Paŭzi", + "video.play": "Legi", + "video.unmute": "Malsilentigi" } diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index d99dacd59..2919928af 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -63,7 +63,7 @@ "confirmations.mute.message": "정말로 {name}를 뮤트하시겠습니까?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", - "embed.instructions": "아래의 코드를 복사하여 대화를 원하는 곳으로 퍼가세요.", + "embed.instructions": "아래의 코드를 복사하여 대화를 원하는 곳으로 공유하세요.", "embed.preview": "다음과 같이 표시됩니다:", "emoji_button.activity": "활동", "emoji_button.custom": "Custom", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 1e0849d95..d826423b5 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -31,7 +31,7 @@ "column.favourites": "Favorits", "column.follow_requests": "Demandas d’abonament", "column.home": "Acuèlh", - "column.mutes": "Personas en silenci", + "column.mutes": "Personas rescondudas", "column.notifications": "Notificacions", "column.pins": "Tuts penjats", "column.public": "Flux public global", @@ -55,12 +55,12 @@ "confirmation_modal.cancel": "Anullar", "confirmations.block.confirm": "Blocar", "confirmations.block.message": "Sètz segur de voler blocar {name} ?", - "confirmations.delete.confirm": "Suprimir", - "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?", + "confirmations.delete.confirm": "Escafar", + "confirmations.delete.message": "Sètz segur de voler escafar l’estatut ?", "confirmations.domain_block.confirm": "Amagar tot lo domeni", "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.", - "confirmations.mute.confirm": "Metre en silenci", - "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?", + "confirmations.mute.confirm": "Rescondre", + "confirmations.mute.message": "Sètz segur de voler rescondre {name} ?", "confirmations.unfollow.confirm": "Quitar de sègre", "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?", "embed.instructions": "Embarcar aqueste estatut per lo far veire sus un site Internet en copiar lo còdi çai-jos.", @@ -135,7 +135,7 @@ "onboarding.page_five.public_timelines": "Lo flux local mòstra los estatuts publics del monde de vòstra instància, aquí {domain}. Lo flux federat mòstra los estatuts publics de la gent que los de {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.", "onboarding.page_four.home": "Lo flux d’acuèlh mòstra los estatuts del mond que seguètz.", "onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un interagís amb vos", - "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum mai larg. Òm los apèla instàncias.", + "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per construire un malhum mai larg. Òm los apèla instàncias.", "onboarding.page_one.handle": "Sètz sus {domain}, doncas vòstre identificant complet es {handle}", "onboarding.page_one.welcome": "Benvengut a Mastodon !", "onboarding.page_six.admin": "Vòstre administrator d’instància es {admin}.", @@ -159,11 +159,11 @@ "privacy.public.short": "Public", "privacy.unlisted.long": "Mostrar pas dins los fluxes publics", "privacy.unlisted.short": "Pas-listat", - "relative_time.days": "fa {number}j", - "relative_time.hours": "fa {number}h", + "relative_time.days": "fa {number} d", + "relative_time.hours": "fa {number} h", "relative_time.just_now": "ara", - "relative_time.minutes": "fa {number} minutas", - "relative_time.seconds": "fa {number} segondas", + "relative_time.minutes": "fa {number} min", + "relative_time.seconds": "fa {number} s", "reply_indicator.cancel": "Anullar", "report.placeholder": "Comentaris addicionals", "report.submit": "Mandar", @@ -197,7 +197,7 @@ "status.share": "Partejar", "status.show_less": "Tornar plegar", "status.show_more": "Desplegar", - "status.unmute_conversation": "Conversacions amb silenci levat", + "status.unmute_conversation": "Tornar mostrar la conversacion", "status.unpin": "Tirar del perfil", "tabs_bar.compose": "Compausar", "tabs_bar.federated_timeline": "Flux public global", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index c0776dfc9..b23a5e69f 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -89,7 +89,7 @@ "follow_request.reject": "Odrzuć", "getting_started.appsshort": "Aplikacje", "getting_started.faq": "FAQ", - "getting_started.heading": "Naucz się korzystać", + "getting_started.heading": "Rozpocznij", "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj: {github}.", "getting_started.userguide": "Podręcznik użytkownika", "home.column_settings.advanced": "Zaawansowane", @@ -159,11 +159,11 @@ "privacy.public.short": "Publiczny", "privacy.unlisted.long": "Niewidoczny na publicznych osiach czasu", "privacy.unlisted.short": "Niewidoczny", - "relative_time.days": "{number}d", - "relative_time.hours": "{number}h", - "relative_time.just_now": "now", - "relative_time.minutes": "{number}m", - "relative_time.seconds": "{number}s", + "relative_time.days": "{number} dni", + "relative_time.hours": "{number} godz.", + "relative_time.just_now": "teraz", + "relative_time.minutes": "{number} min.", + "relative_time.seconds": "{number} s.", "reply_indicator.cancel": "Anuluj", "report.placeholder": "Dodatkowe komentarze", "report.submit": "Wyślij", @@ -174,7 +174,7 @@ "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}}", + "search_results.total": "{count, number} {count, plural, one {wynik} few {wyniki} many {wyników} more {wyników}}", "standalone.public_title": "Spojrzenie w głąb…", "status.cannot_reblog": "Ten wpis nie może zostać podbity", "status.delete": "Usuń", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index ddb8b83f5..a04d1cc31 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -161,7 +161,7 @@ "privacy.unlisted.short": "Não listada", "relative_time.days": "{number}d", "relative_time.hours": "{number}h", - "relative_time.just_now": "now", + "relative_time.just_now": "agora", "relative_time.minutes": "{number}m", "relative_time.seconds": "{number}s", "reply_indicator.cancel": "Cancelar", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 104b063f5..65bc5b374 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -63,20 +63,20 @@ "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?", "confirmations.unfollow.confirm": "Отписаться", "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?", - "embed.instructions": "Embed this status on your website by copying the code below.", - "embed.preview": "Here is what it will look like:", + "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": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!", @@ -159,34 +159,34 @@ "privacy.public.short": "Публичный", "privacy.unlisted.long": "Не показывать в лентах", "privacy.unlisted.short": "Скрытый", - "relative_time.days": "{number}d", - "relative_time.hours": "{number}h", - "relative_time.just_now": "now", - "relative_time.minutes": "{number}m", - "relative_time.seconds": "{number}s", + "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": "Жалуемся на", "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_popout.search_format": "Продвинутый формат поиска", + "search_popout.tips.hashtag": "хэштег", + "search_popout.tips.status": "статус", + "search_popout.tips.text": "Простой ввод текста покажет совпадающие имена пользователей, отображаемые имена и хэштеги", + "search_popout.tips.user": "пользователь", "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", - "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.more": "More", + "status.more": "Больше", "status.mute_conversation": "Заглушить тред", "status.open": "Развернуть статус", - "status.pin": "Pin on profile", + "status.pin": "Закрепить в профиле", "status.reblog": "Продвинуть", "status.reblogged_by": "{name} продвинул(а)", "status.reply": "Ответить", @@ -194,11 +194,11 @@ "status.report": "Пожаловаться", "status.sensitive_toggle": "Нажмите для просмотра", "status.sensitive_warning": "Чувствительный контент", - "status.share": "Share", + "status.share": "Поделиться", "status.show_less": "Свернуть", "status.show_more": "Развернуть", "status.unmute_conversation": "Снять глушение с треда", - "status.unpin": "Unpin from profile", + "status.unpin": "Открепить от профиля", "tabs_bar.compose": "Написать", "tabs_bar.federated_timeline": "Глобальная", "tabs_bar.home": "Главная", @@ -206,16 +206,16 @@ "tabs_bar.notifications": "Уведомления", "upload_area.title": "Перетащите сюда, чтобы загрузить", "upload_button.label": "Добавить медиаконтент", - "upload_form.description": "Describe for the visually impaired", + "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.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/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 827c815cf..0f4bbb7c1 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -1,25 +1,25 @@ { "account.block": "屏蔽 @{name}", "account.block_domain": "隐藏一切来自 {domain} 的嘟文", - "account.disclaimer_full": "下列资料不一定完整。", + "account.disclaimer_full": "此处显示的信息可能不是全部内容。", "account.edit_profile": "修改个人资料", "account.follow": "关注", "account.followers": "关注者", - "account.follows": "正关注", - "account.follows_you": "关注你", + "account.follows": "正在关注", + "account.follows_you": "关注了你", "account.media": "媒体", "account.mention": "提及 @{name}", - "account.mute": "将 @{name} 静音", + "account.mute": "静音 @{name}", "account.posts": "嘟文", "account.report": "举报 @{name}", - "account.requested": "等待审批", - "account.share": "分享 @{name}的个人资料", - "account.unblock": "解除对 @{name} 的屏蔽", + "account.requested": "正在等待对方同意。点击以取消发送关注请求", + "account.share": "分享 @{name} 的个人资料", + "account.unblock": "不再屏蔽 @{name}", "account.unblock_domain": "不再隐藏 {domain}", "account.unfollow": "取消关注", - "account.unmute": "取消 @{name} 的静音", + "account.unmute": "不再静音 @{name}", "account.view_full_profile": "查看完整资料", - "boost_modal.combo": "如你想在下次路过时显示,请按{combo},", + "boost_modal.combo": "下次按住 {combo} 即可跳过此提示", "bundle_column_error.body": "载入组件出错。", "bundle_column_error.retry": "重试", "bundle_column_error.title": "网络错误", @@ -37,72 +37,72 @@ "column.public": "跨站公共时间轴", "column_back_button.label": "返回", "column_header.hide_settings": "隐藏设置", - "column_header.moveLeft_settings": "将栏左移", - "column_header.moveRight_settings": "将栏右移", + "column_header.moveLeft_settings": "将此栏左移", + "column_header.moveRight_settings": "将此栏右移", "column_header.pin": "固定", "column_header.show_settings": "显示设置", - "column_header.unpin": "取下", + "column_header.unpin": "取消固定", "column_subheading.navigation": "导航", "column_subheading.settings": "设置", - "compose_form.lock_disclaimer": "你的帐户没 {locked}. 任何人可以通过关注你来查看只有关注者可见的嘟文.", + "compose_form.lock_disclaimer": "你的帐户没有{locked}。任何人都可以通过关注你来查看仅关注者可见的嘟文。", "compose_form.lock_disclaimer.lock": "被保护", "compose_form.placeholder": "在想啥?", "compose_form.publish": "嘟嘟", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive": "将媒体文件标示为“敏感内容”", - "compose_form.spoiler": "将部分文本藏于警告消息之后", - "compose_form.spoiler_placeholder": "敏感内容的警告消息", + "compose_form.sensitive": "将媒体文件标记为敏感内容", + "compose_form.spoiler": "折叠嘟文内容", + "compose_form.spoiler_placeholder": "折叠部分的警告消息", "confirmation_modal.cancel": "取消", "confirmations.block.confirm": "屏蔽", - "confirmations.block.message": "想好了,真的要屏蔽 {name}?", + "confirmations.block.message": "想好了,真的要屏蔽 {name}?", "confirmations.delete.confirm": "删除", - "confirmations.delete.message": "想好了,真的要删除这条嘟文?", + "confirmations.delete.message": "想好了,真的要删除这条嘟文?", "confirmations.domain_block.confirm": "隐藏整个网站", - "confirmations.domain_block.message": "你真的真的确定要隐藏整个 {domain} ?多数情况下,封锁或静音几个特定目标就好。", + "confirmations.domain_block.message": "你真的真的确定要隐藏整个 {domain}?多数情况下,屏蔽或静音几个特定的用户就应该能满足你的需要了。", "confirmations.mute.confirm": "静音", - "confirmations.mute.message": "想好了,真的要静音 {name}?", + "confirmations.mute.message": "想好了,真的要静音 {name}?", "confirmations.unfollow.confirm": "取消关注", - "confirmations.unfollow.message": "确定要取消关注 {name}吗?", - "embed.instructions": "要内嵌此嘟文,请将以下代码贴进你的网站。", - "embed.preview": "到时大概长这样:", + "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": "加入表情符号", "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": "本站时间轴暂时未有内容,快嘟几个来抢头香啊!", - "empty_column.hashtag": "这个标签暂时未有内容。", + "emoji_button.travel": "旅行和地点", + "empty_column.community": "本站时间轴暂时没有内容,快嘟几个来抢头香啊!", + "empty_column.hashtag": "这个话题标签下暂时没有内容。", "empty_column.home": "你还没有关注任何用户。快看看{public},向其他用户搭讪吧。", "empty_column.home.public_timeline": "公共时间轴", - "empty_column.notifications": "你没有任何通知纪录,快向其他用户搭讪吧。", - "empty_column.public": "跨站公共时间轴暂时没有内容!快写一些公共的嘟文,或者关注另一些服务器实例的用户吧!你和本站、友站的交流,将决定这里出现的内容。", - "follow_request.authorize": "批准", + "empty_column.notifications": "你还没有收到过通知信息,快向其他用户搭讪吧。", + "empty_column.public": "这里神马都没有!写一些公开的嘟文,或者关注其他实例的用户,这里就会有嘟文出现了哦!", + "follow_request.authorize": "同意", "follow_request.reject": "拒绝", - "getting_started.appsshort": "Apps", - "getting_started.faq": "FAQ", + "getting_started.appsshort": "应用", + "getting_started.faq": "常见问题", "getting_started.heading": "开始使用", - "getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。", + "getting_started.open_source_notice": "Mastodon 是一个开源软件。欢迎前往 GitHub({github})贡献代码或反馈问题。", "getting_started.userguide": "用户指南", - "home.column_settings.advanced": "高端", - "home.column_settings.basic": "基本", - "home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤", - "home.column_settings.show_reblogs": "显示被转的嘟文", - "home.column_settings.show_replies": "显示回应嘟文", - "home.settings": "字段设置", + "home.column_settings.advanced": "高级设置", + "home.column_settings.basic": "基本设置", + "home.column_settings.filter_regex": "使用正则表达式(regex)过滤", + "home.column_settings.show_reblogs": "显示转嘟", + "home.column_settings.show_replies": "显示回复", + "home.settings": "栏目设置", "lightbox.close": "关闭", "lightbox.next": "下一步", "lightbox.previous": "上一步", "loading_indicator.label": "加载中……", - "media_gallery.toggle_visible": "打开或关上", + "media_gallery.toggle_visible": "切换显示/隐藏", "missing_indicator.label": "找不到内容", "navigation_bar.blocks": "被屏蔽的用户", "navigation_bar.community_timeline": "本站时间轴", @@ -119,9 +119,9 @@ "notification.follow": "{name} 开始关注你", "notification.mention": "{name} 提及你", "notification.reblog": "{name} 转嘟了你的嘟文", - "notifications.clear": "清空通知纪录", - "notifications.clear_confirmation": "你确定要清空通知纪录吗?", - "notifications.column_settings.alert": "显示桌面通知", + "notifications.clear": "清空通知列表", + "notifications.clear_confirmation": "你确定要清空通知列表吗?", + "notifications.column_settings.alert": "桌面通知", "notifications.column_settings.favourite": "你的嘟文被收藏:", "notifications.column_settings.follow": "关注你:", "notifications.column_settings.mention": "提及你:", @@ -132,90 +132,91 @@ "notifications.column_settings.sound": "播放音效", "onboarding.done": "出发!", "onboarding.next": "下一步", - "onboarding.page_five.public_timelines": "本站时间轴显示来自 {domain} 的所有人的公共嘟文。 跨站公共时间轴显示 {domain} 上的各位关注的来自所有Mastodon服务器实例上的人发表的公共嘟文。这些就是寻人好去处的公共时间轴啦。", - "onboarding.page_four.home": "你的主时间轴上是你关注的用户的嘟文.", - "onboarding.page_four.notifications": "如果你和他人产生了互动,便会出现在通知列上啦~", - "onboarding.page_one.federation": "Mastodon是由一系列独立的服务器共同打造的强大的社交网络,我们将这些独立但又相互连接的服务器叫做服务器实例。", - "onboarding.page_one.handle": "你在 {domain}, {handle} 就是你的完整帐户名称。", - "onboarding.page_one.welcome": "欢迎来到 Mastodon!", + "onboarding.page_five.public_timelines": "本站时间轴显示的是由本站({domain})用户发布的所有公开嘟文。跨站公共时间轴显示的的是由本站用户关注对象所发布的所有公开嘟文。这些就是寻人好去处的公共时间轴啦。", + "onboarding.page_four.home": "你的主页上的时间轴上显示的是你关注对象的嘟文。", + "onboarding.page_four.notifications": "如果有人与你互动,便会出现在通知栏中哦~", + "onboarding.page_one.federation": "Mastodon 是由一系列独立的服务器共同打造的强大的社交网络,我们将这些各自独立但又相互连接的服务器叫做实例。", + "onboarding.page_one.handle": "你在 {domain},{handle} 就是你的完整帐户名称。", + "onboarding.page_one.welcome": "欢迎来到 Mastodon!", "onboarding.page_six.admin": "{admin} 是你所在服务器实例的管理员.", - "onboarding.page_six.almost_done": "差不多了…", - "onboarding.page_six.appetoot": "嗷呜~", - "onboarding.page_six.apps_available": "也有适用于 iOS, Android 和其它平台的 {apps} 咯~", - "onboarding.page_six.github": "Mastodon 是自由的开放源代码软件。欢迎来 {github} 报告问题,提交功能请求,或者贡献代码 :-)", + "onboarding.page_six.almost_done": "差不多了……", + "onboarding.page_six.appetoot": "嗷呜~", + "onboarding.page_six.apps_available": "我们还有适用于 iOS、Android 和其它平台的{apps}哦~", + "onboarding.page_six.github": "Mastodon 是自由的开源软件。欢迎前往 {github} 反馈问题、提出对新功能的建议或贡献代码 :-)", "onboarding.page_six.guidelines": "社区指南", - "onboarding.page_six.read_guidelines": "别忘了看看 {domain} 的 {guidelines}!", - "onboarding.page_six.various_app": "移动应用程序", - "onboarding.page_three.profile": "修改你的个人资料,比如头像、简介、和昵称等等。在那还可以找到其它首选项。", - "onboarding.page_three.search": "用搜索来找人和标签吧,比如 {illustration} 或者 {introductions}。想找其它服务器实例上的人,用完整帐户名称(用户名@域名)啦。", - "onboarding.page_two.compose": "从这里开始嘟!上面的按钮提供了上传图片,修改隐私设置和提示敏感内容等多种功能。.", - "onboarding.skip": "好啦好啦我知道啦", - "privacy.change": "调整隐私设置", - "privacy.direct.long": "只有提及的用户能看到", - "privacy.direct.short": "私人消息", - "privacy.private.long": "只有关注你用户能看到", - "privacy.private.short": "关注者", - "privacy.public.long": "在公共时间轴显示", - "privacy.public.short": "公共", - "privacy.unlisted.long": "公开,但不在公共时间轴显示", - "privacy.unlisted.short": "公开", - "relative_time.days": "{number}d", - "relative_time.hours": "{number}h", - "relative_time.just_now": "now", - "relative_time.minutes": "{number}m", - "relative_time.seconds": "{number}s", + "onboarding.page_six.read_guidelines": "别忘了看看 {domain} 的{guidelines}!", + "onboarding.page_six.various_app": "移动设备应用", + "onboarding.page_three.profile": "你可以修改你的个人资料,比如头像、简介和昵称等偏好设置。", + "onboarding.page_three.search": "你可以通过搜索功能寻找用户和话题标签,比如{illustration}或者{introductions}。如果你想搜索其他实例上的用户,就需要输入完整帐户名称(用户名@域名)哦。", + "onboarding.page_two.compose": "在撰写栏中开始嘟嘟吧!下方的按钮分别用来上传图片,修改嘟文可见范围,以及添加警告信息。", + "onboarding.skip": "跳过", + "privacy.change": "设置嘟文可见范围", + "privacy.direct.long": "只有被提及的用户能看到", + "privacy.direct.short": "私信", + "privacy.private.long": "只有关注你的用户能看到", + "privacy.private.short": "仅关注者", + "privacy.public.long": "所有人可见,并会出现在公共时间轴上", + "privacy.public.short": "公开", + "privacy.unlisted.long": "所有人可见,但不会出现在公共时间轴上", + "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.placeholder": "附言", "report.submit": "提交", - "report.target": "Reporting", + "report.target": "举报 {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 {result} other {results}}", + "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": "没法转嘟这条嘟文啦……", + "status.cannot_reblog": "无法转嘟这条嘟文", "status.delete": "删除", "status.embed": "嵌入", "status.favourite": "收藏", "status.load_more": "加载更多", "status.media_hidden": "隐藏媒体内容", "status.mention": "提及 @{name}", - "status.more": "More", - "status.mute_conversation": "静音对话", + "status.more": "更多", + "status.mute_conversation": "静音此对话", "status.open": "展开嘟文", - "status.pin": "置顶到资料", + "status.pin": "在个人资料页面置顶", "status.reblog": "转嘟", - "status.reblogged_by": "{name} 转嘟", - "status.reply": "回应", - "status.replyAll": "回应整串", + "status.reblogged_by": "{name} 转嘟了", + "status.reply": "回复", + "status.replyAll": "回复所有人", "status.report": "举报 @{name}", "status.sensitive_toggle": "点击显示", "status.sensitive_warning": "敏感内容", - "status.share": "Share", - "status.show_less": "减少显示", - "status.show_more": "显示更多", - "status.unmute_conversation": "解禁对话", - "status.unpin": "解除置顶", + "status.share": "分享", + "status.show_less": "隐藏内容", + "status.show_more": "显示内容", + "status.unmute_conversation": "不再静音此对话", + "status.unpin": "在个人资料页面取消置顶", "tabs_bar.compose": "撰写", "tabs_bar.federated_timeline": "跨站", "tabs_bar.home": "主页", "tabs_bar.local_timeline": "本站", "tabs_bar.notifications": "通知", - "upload_area.title": "将文件拖放至此上传", + "ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会被丢弃。", + "upload_area.title": "将文件拖放到此处开始上传", "upload_button.label": "上传媒体文件", - "upload_form.description": "Describe for the visually impaired", - "upload_form.undo": "还原", - "upload_progress.label": "上传中……", - "video.close": "关闭影片", + "upload_form.description": "为视觉障碍人士添加文字说明", + "upload_form.undo": "取消上传", + "upload_progress.label": "上传中…", + "video.close": "关闭视频", "video.exit_fullscreen": "退出全屏", - "video.expand": "展开影片", + "video.expand": "展开视频", "video.fullscreen": "全屏", - "video.hide": "隐藏影片", + "video.hide": "隐藏视频", "video.mute": "静音", "video.pause": "暂停", "video.play": "播放", - "video.unmute": "解除静音" + "video.unmute": "取消静音" } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 3e9310f16..c709fb88c 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -31,6 +31,7 @@ import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import uuid from '../uuid'; +import { me } from '../initial_state'; const initialState = ImmutableMap({ mounted: false, @@ -49,7 +50,6 @@ const initialState = ImmutableMap({ media_attachments: ImmutableList(), suggestion_token: null, suggestions: ImmutableList(), - me: null, default_privacy: 'public', default_sensitive: false, resetFileKey: Math.floor((Math.random() * 0x10000)), @@ -58,7 +58,6 @@ const initialState = ImmutableMap({ function statusToTextMentions(state, status) { let set = ImmutableOrderedSet([]); - let me = state.get('me'); if (status.getIn(['account', 'id']) !== me) { set = set.add(`@${status.getIn(['account', 'acct'])} `); diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js index f2a8ca5d2..307bcc7dc 100644 --- a/app/javascript/mastodon/reducers/custom_emojis.js +++ b/app/javascript/mastodon/reducers/custom_emojis.js @@ -8,7 +8,7 @@ const initialState = ImmutableList(); export default function custom_emojis(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', []), action.state.getIn(['meta', 'auto_play_gif'], false)) }); + emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); return action.state.get('custom_emojis'); default: return state; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index e65144871..17c870351 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -13,6 +13,7 @@ import settings from './settings'; import push_notifications from './push_notifications'; import status_lists from './status_lists'; import cards from './cards'; +import mutes from './mutes'; import reports from './reports'; import contexts from './contexts'; import compose from './compose'; @@ -37,6 +38,7 @@ const reducers = { settings, push_notifications, cards, + mutes, reports, contexts, compose, diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js index 119ef9d8f..36a5a1c35 100644 --- a/app/javascript/mastodon/reducers/meta.js +++ b/app/javascript/mastodon/reducers/meta.js @@ -4,7 +4,6 @@ import { Map as ImmutableMap } from 'immutable'; const initialState = ImmutableMap({ streaming_api_base_url: null, access_token: null, - me: null, }); export default function meta(state = initialState, action) { diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js new file mode 100644 index 000000000..a96232dbd --- /dev/null +++ b/app/javascript/mastodon/reducers/mutes.js @@ -0,0 +1,29 @@ +import Immutable from 'immutable'; + +import { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, +} from '../actions/mutes'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account: null, + notifications: true, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case MUTES_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'isSubmitting'], false); + state.setIn(['new', 'account'], action.account); + state.setIn(['new', 'notifications'], true); + }); + case MUTES_TOGGLE_HIDE_NOTIFICATIONS: + return state.updateIn(['new', 'notifications'], (old) => !old); + default: + return state; + } +} diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index cccf00a1f..264db4f55 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -99,9 +99,10 @@ export default function notifications(state = initialState, action) { switch(action.type) { case NOTIFICATIONS_REFRESH_REQUEST: case NOTIFICATIONS_EXPAND_REQUEST: + return state.set('isLoading', true); case NOTIFICATIONS_REFRESH_FAIL: case NOTIFICATIONS_EXPAND_FAIL: - return state.set('isLoading', true); + return state.set('isLoading', false); case NOTIFICATIONS_SCROLL_TOP: return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js index 4b36082b2..36c68ffc5 100644 --- a/app/javascript/mastodon/stream.js +++ b/app/javascript/mastodon/stream.js @@ -1,5 +1,66 @@ import WebSocketClient from 'websocket.js'; +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); + let polling = null; + + const setupPolling = () => { + polling = setInterval(() => { + pollingRefresh(dispatch); + }, 20000); + }; + + const clearPolling = () => { + if (polling) { + clearInterval(polling); + polling = null; + } + }; + + const subscription = getStream(streamingAPIBaseURL, accessToken, path, { + connected () { + if (pollingRefresh) { + clearPolling(); + } + onConnect(); + }, + + disconnected () { + if (pollingRefresh) { + setupPolling(); + } + onDisconnect(); + }, + + received (data) { + onReceive(data); + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + onConnect(); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + clearPolling(); + }; + + return disconnect; + }; +} + + export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`); diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 116632dea..ee5bf244c 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,5 +1,15 @@ +// THIS IS THE `vanilla` THEME PACK FILE!! +// IT'S HERE FOR UPSTREAM COMPATIBILITY!! +// THE `glitch` PACK FILE IS IN `themes/glitch/index.js`!! + import loadPolyfills from '../mastodon/load_polyfills'; +// import default stylesheet with variables +import 'font-awesome/css/font-awesome.css'; +import '../styles/application.scss'; + +require.context('../images/', true); + loadPolyfills().then(() => { require('../mastodon/main').default(); }).catch(e => { diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js deleted file mode 100644 index 96e6f4b16..000000000 --- a/app/javascript/packs/common.js +++ /dev/null @@ -1,6 +0,0 @@ -import { start } from 'rails-ujs'; -import 'font-awesome/css/font-awesome.css'; - -require.context('../images/', true); - -start(); diff --git a/app/javascript/styles/common.scss b/app/javascript/styles/common.scss new file mode 100644 index 000000000..c1772e7ae --- /dev/null +++ b/app/javascript/styles/common.scss @@ -0,0 +1,5 @@ +// This makes our fonts available everywhere. + +@import 'fonts/roboto'; +@import 'fonts/roboto-mono'; +@import 'fonts/montserrat'; diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss index 206f1865e..3d2b5a93f 100644 --- a/app/javascript/styles/fonts/montserrat.scss +++ b/app/javascript/styles/fonts/montserrat.scss @@ -1,9 +1,9 @@ @font-face { font-family: 'mastodon-font-display'; src: local('Montserrat'), - url('../fonts/montserrat/Montserrat-Regular.woff2') format('woff2'), - url('../fonts/montserrat/Montserrat-Regular.woff') format('woff'), - url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype'); + url('~fonts/montserrat/Montserrat-Regular.woff2') format('woff2'), + url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'), + url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype'); font-weight: 400; font-style: normal; } @@ -11,7 +11,7 @@ @font-face { font-family: 'mastodon-font-display'; src: local('Montserrat'), - url('../fonts/montserrat/Montserrat-Medium.ttf') format('truetype'); + url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype'); font-weight: 500; font-style: normal; } diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss index 2a1f74e16..c793aa6ed 100644 --- a/app/javascript/styles/fonts/roboto-mono.scss +++ b/app/javascript/styles/fonts/roboto-mono.scss @@ -1,10 +1,10 @@ @font-face { font-family: 'mastodon-font-monospace'; src: local('Roboto Mono'), - url('../fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'), - url('../fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'), - url('../fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'), - url('../fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg'); + url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'), + url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'), + url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'), + url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg'); font-weight: 400; font-style: normal; } diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss index 345d9ad50..79034c018 100644 --- a/app/javascript/styles/fonts/roboto.scss +++ b/app/javascript/styles/fonts/roboto.scss @@ -1,10 +1,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto'), - url('../fonts/roboto/roboto-italic-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-italic-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-italic-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg'); + url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg'); font-weight: normal; font-style: italic; } @@ -12,10 +12,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto'), - url('../fonts/roboto/roboto-bold-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-bold-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-bold-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg'); + url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg'); font-weight: bold; font-style: normal; } @@ -23,10 +23,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto'), - url('../fonts/roboto/roboto-medium-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-medium-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-medium-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg'); + url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg'); font-weight: 500; font-style: normal; } @@ -34,10 +34,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto'), - url('../fonts/roboto/roboto-regular-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-regular-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-regular-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg'); + url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg'); font-weight: normal; font-style: normal; } diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index 30adf8cdc..23e20a366 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -531,7 +531,19 @@ font-size: 12px; line-height: 12px; font-weight: 500; - color: $success-green; - background-color: rgba($success-green, 0.1); - border: 1px solid rgba($success-green, 0.5); + color: $ui-secondary-color; + background-color: rgba($ui-secondary-color, 0.1); + border: 1px solid rgba($ui-secondary-color, 0.5); + + &.moderator { + color: $success-green; + background-color: rgba($success-green, 0.1); + border-color: rgba($success-green, 0.5); + } + + &.admin { + color: lighten($error-red, 12%); + background-color: rgba(lighten($error-red, 12%), 0.1); + border-color: rgba(lighten($error-red, 12%), 0.5); + } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5211489f7..0ded6f159 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -244,6 +244,15 @@ width: 0; height: 0; position: absolute; + + img, + svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; + } } .ellipsis { @@ -510,6 +519,7 @@ font-weight: 400; overflow: hidden; white-space: pre-wrap; + padding-top: 5px; &.status__content--with-spoiler { white-space: normal; @@ -520,8 +530,9 @@ } .emojione { - width: 18px; - height: 18px; + width: 20px; + height: 20px; + margin: -5px 0 0; } p { @@ -601,7 +612,7 @@ outline: 0; background: lighten($ui-base-color, 4%); - &.status-direct { + .status.status-direct { background: lighten($ui-base-color, 12%); } @@ -620,6 +631,12 @@ border-bottom: 1px solid lighten($ui-base-color, 8%); cursor: default; + @supports (-ms-overflow-style: -ms-autohiding-scrollbar) { + // Add margin to avoid Edge auto-hiding scrollbar appearing over content. + // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px. + padding-right: 26px; // 10px + 16px + } + @keyframes fade { 0% { opacity: 0; } 100% { opacity: 1; } @@ -751,7 +768,7 @@ .status__action-bar { align-items: center; display: flex; - margin-top: 10px; + margin-top: 5px; } .status__action-bar-button { @@ -782,8 +799,9 @@ line-height: 24px; .emojione { - width: 22px; - height: 22px; + width: 24px; + height: 24px; + margin: -5px 0 0; } } @@ -888,6 +906,7 @@ .account__relationship { height: 18px; padding: 10px; + white-space: nowrap; } .account__header { @@ -2280,6 +2299,7 @@ button.icon-button.active i.fa-retweet { } .column-header { + display: flex; padding: 15px; font-size: 16px; background: lighten($ui-base-color, 4%); @@ -2305,12 +2325,10 @@ button.icon-button.active i.fa-retweet { } .column-header__buttons { - position: absolute; - right: 0; - top: 0; - height: 100%; - display: flex; height: 48px; + display: flex; + margin: -15px; + margin-left: 0; } .column-header__button { @@ -2378,6 +2396,14 @@ button.icon-button.active i.fa-retweet { } } +.column-header__title { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + .text-btn { display: inline-block; padding: 0; @@ -2581,7 +2607,7 @@ button.icon-button.active i.fa-retweet { color: $primary-text-color; position: absolute; top: 10px; - right: 10px; + left: 10px; opacity: 0.7; display: inline-block; vertical-align: top; @@ -2596,7 +2622,7 @@ button.icon-button.active i.fa-retweet { .account--action-button { position: absolute; top: 10px; - left: 20px; + right: 20px; } .setting-toggle { @@ -3008,21 +3034,21 @@ button.icon-button.active i.fa-retweet { } .fa-search { - transform: translateZ(0) rotate(90deg); + transform: rotate(90deg); &.active { pointer-events: none; - transform: translateZ(0) rotate(0deg); + transform: rotate(0deg); } } .fa-times-circle { top: 11px; - transform: translateZ(0) rotate(0deg); + transform: rotate(0deg); cursor: pointer; &.active { - transform: translateZ(0) rotate(90deg); + transform: rotate(90deg); } &:hover { @@ -3067,7 +3093,6 @@ button.icon-button.active i.fa-retweet { right: 0; bottom: 0; background: rgba($base-overlay-background, 0.7); - transform: translateZ(0); } .modal-root__container { @@ -3491,7 +3516,8 @@ button.icon-button.active i.fa-retweet { .boost-modal, .confirmation-modal, .report-modal, -.actions-modal { +.actions-modal, +.mute-modal { background: lighten($ui-secondary-color, 8%); color: $ui-base-color; border-radius: 8px; @@ -3541,6 +3567,7 @@ button.icon-button.active i.fa-retweet { .boost-modal__action-bar, .confirmation-modal__action-bar, +.mute-modal__action-bar, .report-modal__action-bar { display: flex; justify-content: space-between; @@ -3577,6 +3604,14 @@ button.icon-button.active i.fa-retweet { } } +.mute-modal { + line-height: 24px; +} + +.mute-modal .react-toggle { + vertical-align: middle; +} + .report-modal__statuses, .report-modal__comment { padding: 10px; @@ -3649,8 +3684,10 @@ button.icon-button.active i.fa-retweet { } } -.confirmation-modal__action-bar { - .confirmation-modal__cancel-button { +.confirmation-modal__action-bar, +.mute-modal__action-bar { + .confirmation-modal__cancel-button, + .mute-modal__cancel-button { background-color: transparent; color: darken($ui-secondary-color, 34%); font-size: 14px; @@ -3665,6 +3702,7 @@ button.icon-button.active i.fa-retweet { } .confirmation-modal__container, +.mute-modal__container, .report-modal__target { padding: 30px; font-size: 16px; diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss index 15ff84912..0bf9daafd 100644 --- a/app/javascript/styles/mastodon/landing_strip.scss +++ b/app/javascript/styles/mastodon/landing_strip.scss @@ -1,4 +1,5 @@ -.landing-strip { +.landing-strip, +.memoriam-strip { background: rgba(darken($ui-base-color, 7%), 0.8); color: $ui-primary-color; font-weight: 400; @@ -29,3 +30,7 @@ margin-bottom: 0; } } + +.memoriam-strip { + background: rgba($base-shadow-color, 0.7); +} diff --git a/app/javascript/themes/glitch/actions/accounts.js b/app/javascript/themes/glitch/actions/accounts.js new file mode 100644 index 000000000..f1a8c5471 --- /dev/null +++ b/app/javascript/themes/glitch/actions/accounts.js @@ -0,0 +1,661 @@ +import api, { getLinks } from 'themes/glitch/util/api'; + +export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; +export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; +export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; + +export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; +export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; +export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; + +export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; +export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; +export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; + +export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; +export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; +export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; + +export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; +export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; +export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; + +export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + +export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; +export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; +export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; + +export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + +export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; +export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; +export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; + +export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; +export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; +export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + +export function fetchAccount(id) { + return (dispatch, getState) => { + dispatch(fetchRelationships([id])); + + if (getState().getIn(['accounts', id], null) !== null) { + return; + } + + dispatch(fetchAccountRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}`).then(response => { + dispatch(fetchAccountSuccess(response.data)); + }).catch(error => { + dispatch(fetchAccountFail(id, error)); + }); + }; +}; + +export function fetchAccountRequest(id) { + return { + type: ACCOUNT_FETCH_REQUEST, + id, + }; +}; + +export function fetchAccountSuccess(account) { + return { + type: ACCOUNT_FETCH_SUCCESS, + account, + }; +}; + +export function fetchAccountFail(id, error) { + return { + type: ACCOUNT_FETCH_FAIL, + id, + error, + skipAlert: true, + }; +}; + +export function followAccount(id, reblogs = true) { + return (dispatch, getState) => { + const alreadyFollowing = getState().getIn(['relationships', id, 'following']); + dispatch(followAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { + dispatch(followAccountSuccess(response.data, alreadyFollowing)); + }).catch(error => { + dispatch(followAccountFail(error)); + }); + }; +}; + +export function unfollowAccount(id) { + return (dispatch, getState) => { + dispatch(unfollowAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { + dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(unfollowAccountFail(error)); + }); + }; +}; + +export function followAccountRequest(id) { + return { + type: ACCOUNT_FOLLOW_REQUEST, + id, + }; +}; + +export function followAccountSuccess(relationship, alreadyFollowing) { + return { + type: ACCOUNT_FOLLOW_SUCCESS, + relationship, + alreadyFollowing, + }; +}; + +export function followAccountFail(error) { + return { + type: ACCOUNT_FOLLOW_FAIL, + error, + }; +}; + +export function unfollowAccountRequest(id) { + return { + type: ACCOUNT_UNFOLLOW_REQUEST, + id, + }; +}; + +export function unfollowAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_UNFOLLOW_SUCCESS, + relationship, + statuses, + }; +}; + +export function unfollowAccountFail(error) { + return { + type: ACCOUNT_UNFOLLOW_FAIL, + error, + }; +}; + +export function blockAccount(id) { + return (dispatch, getState) => { + dispatch(blockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(blockAccountFail(id, error)); + }); + }; +}; + +export function unblockAccount(id) { + return (dispatch, getState) => { + dispatch(unblockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { + dispatch(unblockAccountSuccess(response.data)); + }).catch(error => { + dispatch(unblockAccountFail(id, error)); + }); + }; +}; + +export function blockAccountRequest(id) { + return { + type: ACCOUNT_BLOCK_REQUEST, + id, + }; +}; + +export function blockAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_BLOCK_SUCCESS, + relationship, + statuses, + }; +}; + +export function blockAccountFail(error) { + return { + type: ACCOUNT_BLOCK_FAIL, + error, + }; +}; + +export function unblockAccountRequest(id) { + return { + type: ACCOUNT_UNBLOCK_REQUEST, + id, + }; +}; + +export function unblockAccountSuccess(relationship) { + return { + type: ACCOUNT_UNBLOCK_SUCCESS, + relationship, + }; +}; + +export function unblockAccountFail(error) { + return { + type: ACCOUNT_UNBLOCK_FAIL, + error, + }; +}; + + +export function muteAccount(id, notifications) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(muteAccountFail(id, error)); + }); + }; +}; + +export function unmuteAccount(id) { + return (dispatch, getState) => { + dispatch(unmuteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + dispatch(unmuteAccountSuccess(response.data)); + }).catch(error => { + dispatch(unmuteAccountFail(id, error)); + }); + }; +}; + +export function muteAccountRequest(id) { + return { + type: ACCOUNT_MUTE_REQUEST, + id, + }; +}; + +export function muteAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses, + }; +}; + +export function muteAccountFail(error) { + return { + type: ACCOUNT_MUTE_FAIL, + error, + }; +}; + +export function unmuteAccountRequest(id) { + return { + type: ACCOUNT_UNMUTE_REQUEST, + id, + }; +}; + +export function unmuteAccountSuccess(relationship) { + return { + type: ACCOUNT_UNMUTE_SUCCESS, + relationship, + }; +}; + +export function unmuteAccountFail(error) { + return { + type: ACCOUNT_UNMUTE_FAIL, + error, + }; +}; + + +export function fetchFollowers(id) { + return (dispatch, getState) => { + dispatch(fetchFollowersRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; +}; + +export function fetchFollowersRequest(id) { + return { + type: FOLLOWERS_FETCH_REQUEST, + id, + }; +}; + +export function fetchFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_FETCH_SUCCESS, + id, + accounts, + next, + }; +}; + +export function fetchFollowersFail(id, error) { + return { + type: FOLLOWERS_FETCH_FAIL, + id, + error, + }; +}; + +export function expandFollowers(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'followers', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowersFail(id, error)); + }); + }; +}; + +export function expandFollowersRequest(id) { + return { + type: FOLLOWERS_EXPAND_REQUEST, + id, + }; +}; + +export function expandFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +}; + +export function expandFollowersFail(id, error) { + return { + type: FOLLOWERS_EXPAND_FAIL, + id, + error, + }; +}; + +export function fetchFollowing(id) { + return (dispatch, getState) => { + dispatch(fetchFollowingRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; +}; + +export function fetchFollowingRequest(id) { + return { + type: FOLLOWING_FETCH_REQUEST, + id, + }; +}; + +export function fetchFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_FETCH_SUCCESS, + id, + accounts, + next, + }; +}; + +export function fetchFollowingFail(id, error) { + return { + type: FOLLOWING_FETCH_FAIL, + id, + error, + }; +}; + +export function expandFollowing(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'following', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowingRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowingFail(id, error)); + }); + }; +}; + +export function expandFollowingRequest(id) { + return { + type: FOLLOWING_EXPAND_REQUEST, + id, + }; +}; + +export function expandFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_EXPAND_SUCCESS, + id, + accounts, + next, + }; +}; + +export function expandFollowingFail(id, error) { + return { + type: FOLLOWING_EXPAND_FAIL, + id, + error, + }; +}; + +export function fetchRelationships(accountIds) { + return (dispatch, getState) => { + const loadedRelationships = getState().get('relationships'); + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newAccountIds.length === 0) { + return; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchRelationshipsSuccess(response.data)); + }).catch(error => { + dispatch(fetchRelationshipsFail(error)); + }); + }; +}; + +export function fetchRelationshipsRequest(ids) { + return { + type: RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, + }; +}; + +export function fetchRelationshipsSuccess(relationships) { + return { + type: RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, + }; +}; + +export function fetchRelationshipsFail(error) { + return { + type: RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, + }; +}; + +export function fetchFollowRequests() { + return (dispatch, getState) => { + dispatch(fetchFollowRequestsRequest()); + + api(getState).get('/api/v1/follow_requests').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(fetchFollowRequestsFail(error))); + }; +}; + +export function fetchFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_FETCH_REQUEST, + }; +}; + +export function fetchFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next, + }; +}; + +export function fetchFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_FETCH_FAIL, + error, + }; +}; + +export function expandFollowRequests() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'follow_requests', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(expandFollowRequestsFail(error))); + }; +}; + +export function expandFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_EXPAND_REQUEST, + }; +}; + +export function expandFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next, + }; +}; + +export function expandFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error, + }; +}; + +export function authorizeFollowRequest(id) { + return (dispatch, getState) => { + dispatch(authorizeFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(() => dispatch(authorizeFollowRequestSuccess(id))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; +}; + +export function authorizeFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id, + }; +}; + +export function authorizeFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + id, + }; +}; + +export function authorizeFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error, + }; +}; + + +export function rejectFollowRequest(id) { + return (dispatch, getState) => { + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(() => dispatch(rejectFollowRequestSuccess(id))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; +}; + +export function rejectFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_REJECT_REQUEST, + id, + }; +}; + +export function rejectFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_REJECT_SUCCESS, + id, + }; +}; + +export function rejectFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error, + }; +}; diff --git a/app/javascript/themes/glitch/actions/alerts.js b/app/javascript/themes/glitch/actions/alerts.js new file mode 100644 index 000000000..f37fdeeb6 --- /dev/null +++ b/app/javascript/themes/glitch/actions/alerts.js @@ -0,0 +1,24 @@ +export const ALERT_SHOW = 'ALERT_SHOW'; +export const ALERT_DISMISS = 'ALERT_DISMISS'; +export const ALERT_CLEAR = 'ALERT_CLEAR'; + +export function dismissAlert(alert) { + return { + type: ALERT_DISMISS, + alert, + }; +}; + +export function clearAlert() { + return { + type: ALERT_CLEAR, + }; +}; + +export function showAlert(title, message) { + return { + type: ALERT_SHOW, + title, + message, + }; +}; diff --git a/app/javascript/themes/glitch/actions/blocks.js b/app/javascript/themes/glitch/actions/blocks.js new file mode 100644 index 000000000..6ba9460f0 --- /dev/null +++ b/app/javascript/themes/glitch/actions/blocks.js @@ -0,0 +1,82 @@ +import api, { getLinks } from 'themes/glitch/util/api'; +import { fetchRelationships } from './accounts'; + +export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; +export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; +export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; + +export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; +export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; +export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; + +export function fetchBlocks() { + return (dispatch, getState) => { + dispatch(fetchBlocksRequest()); + + api(getState).get('/api/v1/blocks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchBlocksFail(error))); + }; +}; + +export function fetchBlocksRequest() { + return { + type: BLOCKS_FETCH_REQUEST, + }; +}; + +export function fetchBlocksSuccess(accounts, next) { + return { + type: BLOCKS_FETCH_SUCCESS, + accounts, + next, + }; +}; + +export function fetchBlocksFail(error) { + return { + type: BLOCKS_FETCH_FAIL, + error, + }; +}; + +export function expandBlocks() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'blocks', 'next']); + + if (url === null) { + return; + } + + dispatch(expandBlocksRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandBlocksFail(error))); + }; +}; + +export function expandBlocksRequest() { + return { + type: BLOCKS_EXPAND_REQUEST, + }; +}; + +export function expandBlocksSuccess(accounts, next) { + return { + type: BLOCKS_EXPAND_SUCCESS, + accounts, + next, + }; +}; + +export function expandBlocksFail(error) { + return { + type: BLOCKS_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/themes/glitch/actions/bundles.js b/app/javascript/themes/glitch/actions/bundles.js new file mode 100644 index 000000000..ecc9c8f7d --- /dev/null +++ b/app/javascript/themes/glitch/actions/bundles.js @@ -0,0 +1,25 @@ +export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; +export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; +export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; + +export function fetchBundleRequest(skipLoading) { + return { + type: BUNDLE_FETCH_REQUEST, + skipLoading, + }; +} + +export function fetchBundleSuccess(skipLoading) { + return { + type: BUNDLE_FETCH_SUCCESS, + skipLoading, + }; +} + +export function fetchBundleFail(error, skipLoading) { + return { + type: BUNDLE_FETCH_FAIL, + error, + skipLoading, + }; +} diff --git a/app/javascript/themes/glitch/actions/cards.js b/app/javascript/themes/glitch/actions/cards.js new file mode 100644 index 000000000..2a1bc369a --- /dev/null +++ b/app/javascript/themes/glitch/actions/cards.js @@ -0,0 +1,52 @@ +import api from 'themes/glitch/util/api'; + +export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; +export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; +export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; + +export function fetchStatusCard(id) { + return (dispatch, getState) => { + if (getState().getIn(['cards', id], null) !== null) { + return; + } + + dispatch(fetchStatusCardRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { + if (!response.data.url) { + return; + } + + dispatch(fetchStatusCardSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchStatusCardFail(id, error)); + }); + }; +}; + +export function fetchStatusCardRequest(id) { + return { + type: STATUS_CARD_FETCH_REQUEST, + id, + skipLoading: true, + }; +}; + +export function fetchStatusCardSuccess(id, card) { + return { + type: STATUS_CARD_FETCH_SUCCESS, + id, + card, + skipLoading: true, + }; +}; + +export function fetchStatusCardFail(id, error) { + return { + type: STATUS_CARD_FETCH_FAIL, + id, + error, + skipLoading: true, + skipAlert: true, + }; +}; diff --git a/app/javascript/themes/glitch/actions/columns.js b/app/javascript/themes/glitch/actions/columns.js new file mode 100644 index 000000000..bcb0cdf98 --- /dev/null +++ b/app/javascript/themes/glitch/actions/columns.js @@ -0,0 +1,40 @@ +import { saveSettings } from './settings'; + +export const COLUMN_ADD = 'COLUMN_ADD'; +export const COLUMN_REMOVE = 'COLUMN_REMOVE'; +export const COLUMN_MOVE = 'COLUMN_MOVE'; + +export function addColumn(id, params) { + return dispatch => { + dispatch({ + type: COLUMN_ADD, + id, + params, + }); + + dispatch(saveSettings()); + }; +}; + +export function removeColumn(uuid) { + return dispatch => { + dispatch({ + type: COLUMN_REMOVE, + uuid, + }); + + dispatch(saveSettings()); + }; +}; + +export function moveColumn(uuid, direction) { + return dispatch => { + dispatch({ + type: COLUMN_MOVE, + uuid, + direction, + }); + + dispatch(saveSettings()); + }; +}; diff --git a/app/javascript/themes/glitch/actions/compose.js b/app/javascript/themes/glitch/actions/compose.js new file mode 100644 index 000000000..07c469477 --- /dev/null +++ b/app/javascript/themes/glitch/actions/compose.js @@ -0,0 +1,398 @@ +import api from 'themes/glitch/util/api'; +import { throttle } from 'lodash'; +import { search as emojiSearch } from 'themes/glitch/util/emoji/emoji_mart_search_light'; +import { useEmoji } from './emojis'; + +import { + updateTimeline, + refreshHomeTimeline, + refreshCommunityTimeline, + refreshPublicTimeline, + refreshDirectTimeline, +} from './timelines'; + +export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; +export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; +export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; +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'; +export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; +export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; + +export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; +export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; +export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; + +export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; +export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; + +export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE'; +export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +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 const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET'; + +export function changeCompose(text) { + return { + type: COMPOSE_CHANGE, + text: text, + }; +}; + +export function replyCompose(status, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_REPLY, + status: status, + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + +export function cancelReplyCompose() { + return { + type: COMPOSE_REPLY_CANCEL, + }; +}; + +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +}; + +export function mentionCompose(account, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_MENTION, + account: account, + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + +export function submitCompose() { + return function (dispatch, getState) { + let status = getState().getIn(['compose', 'text'], ''); + + if (!status || !status.length) { + return; + } + + dispatch(submitComposeRequest()); + if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { + status = status + ' 👁️'; + } + api(getState).post('/api/v1/statuses', { + status, + in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), + media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), + sensitive: getState().getIn(['compose', 'sensitive']), + spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), + visibility: getState().getIn(['compose', 'privacy']), + }, { + headers: { + 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), + }, + }).then(function (response) { + dispatch(submitComposeSuccess({ ...response.data })); + + // To make the app more responsive, immediately get the status into the columns + + const insertOrRefresh = (timelineId, refreshAction) => { + if (getState().getIn(['timelines', timelineId, 'online'])) { + dispatch(updateTimeline(timelineId, { ...response.data })); + } else if (getState().getIn(['timelines', timelineId, 'loaded'])) { + dispatch(refreshAction()); + } + }; + + insertOrRefresh('home', refreshHomeTimeline); + + if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + insertOrRefresh('community', refreshCommunityTimeline); + insertOrRefresh('public', refreshPublicTimeline); + } else if (response.data.visibility === 'direct') { + insertOrRefresh('direct', refreshDirectTimeline); + } + }).catch(function (error) { + dispatch(submitComposeFail(error)); + }); + }; +}; + +export function submitComposeRequest() { + return { + type: COMPOSE_SUBMIT_REQUEST, + }; +}; + +export function submitComposeSuccess(status) { + return { + type: COMPOSE_SUBMIT_SUCCESS, + status: status, + }; +}; + +export function submitComposeFail(error) { + return { + type: COMPOSE_SUBMIT_FAIL, + error: error, + }; +}; + +export function doodleSet(options) { + return { + type: COMPOSE_DOODLE_SET, + options: options, + }; +}; + +export function uploadCompose(files) { + return function (dispatch, getState) { + if (getState().getIn(['compose', 'media_attachments']).size > 3) { + return; + } + + dispatch(uploadComposeRequest()); + + let data = new FormData(); + data.append('file', files[0]); + + api(getState).post('/api/v1/media', data, { + onUploadProgress: function (e) { + dispatch(uploadComposeProgress(e.loaded, e.total)); + }, + }).then(function (response) { + dispatch(uploadComposeSuccess(response.data)); + }).catch(function (error) { + dispatch(uploadComposeFail(error)); + }); + }; +}; + +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, + skipLoading: true, + }; +}; + +export function uploadComposeProgress(loaded, total) { + return { + type: COMPOSE_UPLOAD_PROGRESS, + loaded: loaded, + total: total, + }; +}; + +export function uploadComposeSuccess(media) { + return { + type: COMPOSE_UPLOAD_SUCCESS, + media: media, + skipLoading: true, + }; +}; + +export function uploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_FAIL, + error: error, + skipLoading: true, + }; +}; + +export function undoUploadCompose(media_id) { + return { + type: COMPOSE_UPLOAD_UNDO, + media_id: media_id, + }; +}; + +export function clearComposeSuggestions() { + return { + type: COMPOSE_SUGGESTIONS_CLEAR, + }; +}; + +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] === ':') { + fetchComposeSuggestionsEmojis(dispatch, getState, token); + } else { + fetchComposeSuggestionsAccounts(dispatch, getState, token); + } + }; +}; + +export function readyComposeSuggestionsEmojis(token, emojis) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, + }; +}; + +export function readyComposeSuggestionsAccounts(token, accounts) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + accounts, + }; +}; + +export function selectComposeSuggestion(position, token, suggestion) { + return (dispatch, getState) => { + let completion, startPosition; + + 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; + } + + dispatch({ + type: COMPOSE_SUGGESTION_SELECT, + position: startPosition, + token, + completion, + }); + }; +}; + +export function mountCompose() { + return { + type: COMPOSE_MOUNT, + }; +}; + +export function unmountCompose() { + return { + type: COMPOSE_UNMOUNT, + }; +}; + +export function toggleComposeAdvancedOption(option) { + return { + type: COMPOSE_ADVANCED_OPTIONS_CHANGE, + option: option, + }; +} + +export function changeComposeSensitivity() { + return { + type: COMPOSE_SENSITIVITY_CHANGE, + }; +}; + +export function changeComposeSpoilerness() { + return { + type: COMPOSE_SPOILERNESS_CHANGE, + }; +}; + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text, + }; +}; + +export function changeComposeVisibility(value) { + return { + type: COMPOSE_VISIBILITY_CHANGE, + value, + }; +}; + +export function insertEmojiCompose(position, emoji) { + return { + type: COMPOSE_EMOJI_INSERT, + position, + emoji, + }; +}; + +export function changeComposing(value) { + return { + type: COMPOSE_COMPOSING_CHANGE, + value, + }; +} diff --git a/app/javascript/themes/glitch/actions/domain_blocks.js b/app/javascript/themes/glitch/actions/domain_blocks.js new file mode 100644 index 000000000..0a880394a --- /dev/null +++ b/app/javascript/themes/glitch/actions/domain_blocks.js @@ -0,0 +1,117 @@ +import api, { getLinks } from 'themes/glitch/util/api'; + +export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; +export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; +export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; + +export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; +export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; +export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; + +export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; +export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; +export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; + +export function blockDomain(domain, accountId) { + return (dispatch, getState) => { + dispatch(blockDomainRequest(domain)); + + api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { + dispatch(blockDomainSuccess(domain, accountId)); + }).catch(err => { + dispatch(blockDomainFail(domain, err)); + }); + }; +}; + +export function blockDomainRequest(domain) { + return { + type: DOMAIN_BLOCK_REQUEST, + domain, + }; +}; + +export function blockDomainSuccess(domain, accountId) { + return { + type: DOMAIN_BLOCK_SUCCESS, + domain, + accountId, + }; +}; + +export function blockDomainFail(domain, error) { + return { + type: DOMAIN_BLOCK_FAIL, + domain, + error, + }; +}; + +export function unblockDomain(domain, accountId) { + return (dispatch, getState) => { + dispatch(unblockDomainRequest(domain)); + + api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { + dispatch(unblockDomainSuccess(domain, accountId)); + }).catch(err => { + dispatch(unblockDomainFail(domain, err)); + }); + }; +}; + +export function unblockDomainRequest(domain) { + return { + type: DOMAIN_UNBLOCK_REQUEST, + domain, + }; +}; + +export function unblockDomainSuccess(domain, accountId) { + return { + type: DOMAIN_UNBLOCK_SUCCESS, + domain, + accountId, + }; +}; + +export function unblockDomainFail(domain, error) { + return { + type: DOMAIN_UNBLOCK_FAIL, + domain, + error, + }; +}; + +export function fetchDomainBlocks() { + return (dispatch, getState) => { + dispatch(fetchDomainBlocksRequest()); + + api(getState).get().then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchDomainBlocksFail(err)); + }); + }; +}; + +export function fetchDomainBlocksRequest() { + return { + type: DOMAIN_BLOCKS_FETCH_REQUEST, + }; +}; + +export function fetchDomainBlocksSuccess(domains, next) { + return { + type: DOMAIN_BLOCKS_FETCH_SUCCESS, + domains, + next, + }; +}; + +export function fetchDomainBlocksFail(error) { + return { + type: DOMAIN_BLOCKS_FETCH_FAIL, + error, + }; +}; diff --git a/app/javascript/themes/glitch/actions/emojis.js b/app/javascript/themes/glitch/actions/emojis.js new file mode 100644 index 000000000..7cd9d4b7b --- /dev/null +++ b/app/javascript/themes/glitch/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/themes/glitch/actions/favourites.js b/app/javascript/themes/glitch/actions/favourites.js new file mode 100644 index 000000000..e9b3559af --- /dev/null +++ b/app/javascript/themes/glitch/actions/favourites.js @@ -0,0 +1,83 @@ +import api, { getLinks } from 'themes/glitch/util/api'; + +export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; +export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; +export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; + +export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; +export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; +export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; + +export function fetchFavouritedStatuses() { + return (dispatch, getState) => { + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; +}; + +export function fetchFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_FETCH_FAIL, + error, + }; +}; + +export function expandFavouritedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'favourites', 'next'], null); + + if (url === null) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFavouritedStatusesFail(error)); + }); + }; +}; + +export function expandFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_EXPAND_REQUEST, + }; +}; + +export function expandFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +}; + +export function expandFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/themes/glitch/actions/height_cache.js b/app/javascript/themes/glitch/actions/height_cache.js new file mode 100644 index 000000000..4c752993f --- /dev/null +++ b/app/javascript/themes/glitch/actions/height_cache.js @@ -0,0 +1,17 @@ +export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET'; +export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR'; + +export function setHeight (key, id, height) { + return { + type: HEIGHT_CACHE_SET, + key, + id, + height, + }; +}; + +export function clearHeight () { + return { + type: HEIGHT_CACHE_CLEAR, + }; +}; diff --git a/app/javascript/themes/glitch/actions/interactions.js b/app/javascript/themes/glitch/actions/interactions.js new file mode 100644 index 000000000..d61a7ba2a --- /dev/null +++ b/app/javascript/themes/glitch/actions/interactions.js @@ -0,0 +1,313 @@ +import api from 'themes/glitch/util/api'; + +export const REBLOG_REQUEST = 'REBLOG_REQUEST'; +export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; +export const REBLOG_FAIL = 'REBLOG_FAIL'; + +export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; +export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; +export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; + +export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; +export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; +export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; + +export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; +export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; +export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; + +export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; +export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; +export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; + +export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; +export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; +export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; + +export const PIN_REQUEST = 'PIN_REQUEST'; +export const PIN_SUCCESS = 'PIN_SUCCESS'; +export const PIN_FAIL = 'PIN_FAIL'; + +export const UNPIN_REQUEST = 'UNPIN_REQUEST'; +export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +export const UNPIN_FAIL = 'UNPIN_FAIL'; + +export function reblog(status) { + return function (dispatch, getState) { + dispatch(reblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(reblogSuccess(status, response.data.reblog)); + }).catch(function (error) { + dispatch(reblogFail(status, error)); + }); + }; +}; + +export function unreblog(status) { + return (dispatch, getState) => { + dispatch(unreblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { + dispatch(unreblogSuccess(status, response.data)); + }).catch(error => { + dispatch(unreblogFail(status, error)); + }); + }; +}; + +export function reblogRequest(status) { + return { + type: REBLOG_REQUEST, + status: status, + }; +}; + +export function reblogSuccess(status, response) { + return { + type: REBLOG_SUCCESS, + status: status, + response: response, + }; +}; + +export function reblogFail(status, error) { + return { + type: REBLOG_FAIL, + status: status, + error: error, + }; +}; + +export function unreblogRequest(status) { + return { + type: UNREBLOG_REQUEST, + status: status, + }; +}; + +export function unreblogSuccess(status, response) { + return { + type: UNREBLOG_SUCCESS, + status: status, + response: response, + }; +}; + +export function unreblogFail(status, error) { + return { + type: UNREBLOG_FAIL, + status: status, + error: error, + }; +}; + +export function favourite(status) { + return function (dispatch, getState) { + dispatch(favouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { + dispatch(favouriteSuccess(status, response.data)); + }).catch(function (error) { + dispatch(favouriteFail(status, error)); + }); + }; +}; + +export function unfavourite(status) { + return (dispatch, getState) => { + dispatch(unfavouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { + dispatch(unfavouriteSuccess(status, response.data)); + }).catch(error => { + dispatch(unfavouriteFail(status, error)); + }); + }; +}; + +export function favouriteRequest(status) { + return { + type: FAVOURITE_REQUEST, + status: status, + }; +}; + +export function favouriteSuccess(status, response) { + return { + type: FAVOURITE_SUCCESS, + status: status, + response: response, + }; +}; + +export function favouriteFail(status, error) { + return { + type: FAVOURITE_FAIL, + status: status, + error: error, + }; +}; + +export function unfavouriteRequest(status) { + return { + type: UNFAVOURITE_REQUEST, + status: status, + }; +}; + +export function unfavouriteSuccess(status, response) { + return { + type: UNFAVOURITE_SUCCESS, + status: status, + response: response, + }; +}; + +export function unfavouriteFail(status, error) { + return { + type: UNFAVOURITE_FAIL, + status: status, + error: error, + }; +}; + +export function fetchReblogs(id) { + return (dispatch, getState) => { + dispatch(fetchReblogsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + dispatch(fetchReblogsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchReblogsFail(id, error)); + }); + }; +}; + +export function fetchReblogsRequest(id) { + return { + type: REBLOGS_FETCH_REQUEST, + id, + }; +}; + +export function fetchReblogsSuccess(id, accounts) { + return { + type: REBLOGS_FETCH_SUCCESS, + id, + accounts, + }; +}; + +export function fetchReblogsFail(id, error) { + return { + type: REBLOGS_FETCH_FAIL, + error, + }; +}; + +export function fetchFavourites(id) { + return (dispatch, getState) => { + dispatch(fetchFavouritesRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + dispatch(fetchFavouritesSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchFavouritesFail(id, error)); + }); + }; +}; + +export function fetchFavouritesRequest(id) { + return { + type: FAVOURITES_FETCH_REQUEST, + id, + }; +}; + +export function fetchFavouritesSuccess(id, accounts) { + return { + type: FAVOURITES_FETCH_SUCCESS, + id, + accounts, + }; +}; + +export function fetchFavouritesFail(id, error) { + return { + type: FAVOURITES_FETCH_FAIL, + error, + }; +}; + +export function pin(status) { + return (dispatch, getState) => { + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + dispatch(pinSuccess(status, response.data)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; +}; + +export function pinRequest(status) { + return { + type: PIN_REQUEST, + status, + }; +}; + +export function pinSuccess(status, response) { + return { + type: PIN_SUCCESS, + status, + response, + }; +}; + +export function pinFail(status, error) { + return { + type: PIN_FAIL, + status, + error, + }; +}; + +export function unpin (status) { + return (dispatch, getState) => { + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + dispatch(unpinSuccess(status, response.data)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; +}; + +export function unpinRequest(status) { + return { + type: UNPIN_REQUEST, + status, + }; +}; + +export function unpinSuccess(status, response) { + return { + type: UNPIN_SUCCESS, + status, + response, + }; +}; + +export function unpinFail(status, error) { + return { + type: UNPIN_FAIL, + status, + error, + }; +}; diff --git a/app/javascript/themes/glitch/actions/local_settings.js b/app/javascript/themes/glitch/actions/local_settings.js new file mode 100644 index 000000000..28660a4e8 --- /dev/null +++ b/app/javascript/themes/glitch/actions/local_settings.js @@ -0,0 +1,24 @@ +export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE'; + +export function changeLocalSetting(key, value) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_CHANGE, + key, + value, + }); + + dispatch(saveLocalSettings()); + }; +}; + +// __TODO :__ +// Right now `saveLocalSettings()` doesn't keep track of which user +// is currently signed in, but it might be better to give each user +// their *own* local settings. +export function saveLocalSettings() { + return (_, getState) => { + const localSettings = getState().get('local_settings').toJS(); + localStorage.setItem('mastodon-settings', JSON.stringify(localSettings)); + }; +}; diff --git a/app/javascript/themes/glitch/actions/modal.js b/app/javascript/themes/glitch/actions/modal.js new file mode 100644 index 000000000..80e15c28e --- /dev/null +++ b/app/javascript/themes/glitch/actions/modal.js @@ -0,0 +1,16 @@ +export const MODAL_OPEN = 'MODAL_OPEN'; +export const MODAL_CLOSE = 'MODAL_CLOSE'; + +export function openModal(type, props) { + return { + type: MODAL_OPEN, + modalType: type, + modalProps: props, + }; +}; + +export function closeModal() { + return { + type: MODAL_CLOSE, + }; +}; diff --git a/app/javascript/themes/glitch/actions/mutes.js b/app/javascript/themes/glitch/actions/mutes.js new file mode 100644 index 000000000..bb19e8657 --- /dev/null +++ b/app/javascript/themes/glitch/actions/mutes.js @@ -0,0 +1,103 @@ +import api, { getLinks } from 'themes/glitch/util/api'; +import { fetchRelationships } from './accounts'; +import { openModal } from 'themes/glitch/actions/modal'; + +export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; +export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; +export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; + +export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; +export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; +export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; + +export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; + +export function fetchMutes() { + return (dispatch, getState) => { + dispatch(fetchMutesRequest()); + + api(getState).get('/api/v1/mutes').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchMutesFail(error))); + }; +}; + +export function fetchMutesRequest() { + return { + type: MUTES_FETCH_REQUEST, + }; +}; + +export function fetchMutesSuccess(accounts, next) { + return { + type: MUTES_FETCH_SUCCESS, + accounts, + next, + }; +}; + +export function fetchMutesFail(error) { + return { + type: MUTES_FETCH_FAIL, + error, + }; +}; + +export function expandMutes() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'mutes', 'next']); + + if (url === null) { + return; + } + + dispatch(expandMutesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandMutesFail(error))); + }; +}; + +export function expandMutesRequest() { + return { + type: MUTES_EXPAND_REQUEST, + }; +}; + +export function expandMutesSuccess(accounts, next) { + return { + type: MUTES_EXPAND_SUCCESS, + accounts, + next, + }; +}; + +export function expandMutesFail(error) { + return { + type: MUTES_EXPAND_FAIL, + error, + }; +}; + +export function initMuteModal(account) { + return dispatch => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal('MUTE')); + }; +} + +export function toggleHideNotifications() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; +} diff --git a/app/javascript/themes/glitch/actions/notifications.js b/app/javascript/themes/glitch/actions/notifications.js new file mode 100644 index 000000000..fbf06f7c4 --- /dev/null +++ b/app/javascript/themes/glitch/actions/notifications.js @@ -0,0 +1,265 @@ +import api, { getLinks } from 'themes/glitch/util/api'; +import { List as ImmutableList } from 'immutable'; +import IntlMessageFormat from 'intl-messageformat'; +import { fetchRelationships } from './accounts'; +import { defineMessages } from 'react-intl'; + +export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; + +// tracking the notif cleaning request +export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST'; +export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS'; +export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL'; +export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE'; +export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes +// Unmark notifications (when the cleaning mode is left) +export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE'; +// Mark one for delete +export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE'; + +export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; +export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; +export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; + +export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; +export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; +export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; + +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; + +defineMessages({ + mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, +}); + +const fetchRelatedRelationships = (dispatch, notifications) => { + const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); + + if (accountIds > 0) { + dispatch(fetchRelationships(accountIds)); + } +}; + +const unescapeHTML = (html) => { + const wrapper = document.createElement('div'); + html = html.replace(/<br \/>|<br>|\n/, ' '); + wrapper.innerHTML = html; + return wrapper.textContent; +}; + +export function updateNotifications(notification, intlMessages, intlLocale) { + return (dispatch, getState) => { + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + account: notification.account, + status: notification.status, + meta: playSound ? { sound: 'boop' } : undefined, + }); + + fetchRelatedRelationships(dispatch, [notification]); + + // Desktop notifications + if (typeof window.Notification !== 'undefined' && showAlert) { + const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); + + const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); + notify.addEventListener('click', () => { + window.focus(); + notify.close(); + }); + } + }; +}; + +const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); + +export function refreshNotifications() { + return (dispatch, getState) => { + const params = {}; + const ids = getState().getIn(['notifications', 'items']); + + let skipLoading = false; + + if (ids.size > 0) { + params.since_id = ids.first().get('id'); + } + + if (getState().getIn(['notifications', 'loaded'])) { + skipLoading = true; + } + + params.exclude_types = excludeTypesFromSettings(getState()); + + dispatch(refreshNotificationsRequest(skipLoading)); + + api(getState).get('/api/v1/notifications', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null)); + fetchRelatedRelationships(dispatch, response.data); + }).catch(error => { + dispatch(refreshNotificationsFail(error, skipLoading)); + }); + }; +}; + +export function refreshNotificationsRequest(skipLoading) { + return { + type: NOTIFICATIONS_REFRESH_REQUEST, + skipLoading, + }; +}; + +export function refreshNotificationsSuccess(notifications, skipLoading, next) { + return { + type: NOTIFICATIONS_REFRESH_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status).filter(status => !!status), + skipLoading, + next, + }; +}; + +export function refreshNotificationsFail(error, skipLoading) { + return { + type: NOTIFICATIONS_REFRESH_FAIL, + error, + skipLoading, + }; +}; + +export function expandNotifications() { + return (dispatch, getState) => { + const items = getState().getIn(['notifications', 'items'], ImmutableList()); + + if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) { + return; + } + + const params = { + max_id: items.last().get('id'), + limit: 20, + exclude_types: excludeTypesFromSettings(getState()), + }; + + dispatch(expandNotificationsRequest()); + + api(getState).get('/api/v1/notifications', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); + fetchRelatedRelationships(dispatch, response.data); + }).catch(error => { + dispatch(expandNotificationsFail(error)); + }); + }; +}; + +export function expandNotificationsRequest() { + return { + type: NOTIFICATIONS_EXPAND_REQUEST, + }; +}; + +export function expandNotificationsSuccess(notifications, next) { + return { + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status).filter(status => !!status), + next, + }; +}; + +export function expandNotificationsFail(error) { + return { + type: NOTIFICATIONS_EXPAND_FAIL, + error, + }; +}; + +export function clearNotifications() { + return (dispatch, getState) => { + dispatch({ + type: NOTIFICATIONS_CLEAR, + }); + + api(getState).post('/api/v1/notifications/clear'); + }; +}; + +export function scrollTopNotifications(top) { + return { + type: NOTIFICATIONS_SCROLL_TOP, + top, + }; +}; + +export function deleteMarkedNotifications() { + return (dispatch, getState) => { + dispatch(deleteMarkedNotificationsRequest()); + + let ids = []; + getState().getIn(['notifications', 'items']).forEach((n) => { + if (n.get('markedForDelete')) { + ids.push(n.get('id')); + } + }); + + if (ids.length === 0) { + return; + } + + api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => { + dispatch(deleteMarkedNotificationsSuccess()); + }).catch(error => { + console.error(error); + dispatch(deleteMarkedNotificationsFail(error)); + }); + }; +}; + +export function enterNotificationClearingMode(yes) { + return { + type: NOTIFICATIONS_ENTER_CLEARING_MODE, + yes: yes, + }; +}; + +export function markAllNotifications(yes) { + return { + type: NOTIFICATIONS_MARK_ALL_FOR_DELETE, + yes: yes, // true, false or null. null = invert + }; +}; + +export function deleteMarkedNotificationsRequest() { + return { + type: NOTIFICATIONS_DELETE_MARKED_REQUEST, + }; +}; + +export function deleteMarkedNotificationsFail() { + return { + type: NOTIFICATIONS_DELETE_MARKED_FAIL, + }; +}; + +export function markNotificationForDelete(id, yes) { + return { + type: NOTIFICATION_MARK_FOR_DELETE, + id: id, + yes: yes, + }; +}; + +export function deleteMarkedNotificationsSuccess() { + return { + type: NOTIFICATIONS_DELETE_MARKED_SUCCESS, + }; +}; diff --git a/app/javascript/themes/glitch/actions/onboarding.js b/app/javascript/themes/glitch/actions/onboarding.js new file mode 100644 index 000000000..a161c50ef --- /dev/null +++ b/app/javascript/themes/glitch/actions/onboarding.js @@ -0,0 +1,14 @@ +import { openModal } from './modal'; +import { changeSetting, saveSettings } from './settings'; + +export function showOnboardingOnce() { + return (dispatch, getState) => { + const alreadySeen = getState().getIn(['settings', 'onboarded']); + + if (!alreadySeen) { + dispatch(openModal('ONBOARDING')); + dispatch(changeSetting(['onboarded'], true)); + dispatch(saveSettings()); + } + }; +}; diff --git a/app/javascript/themes/glitch/actions/pin_statuses.js b/app/javascript/themes/glitch/actions/pin_statuses.js new file mode 100644 index 000000000..b3e064e58 --- /dev/null +++ b/app/javascript/themes/glitch/actions/pin_statuses.js @@ -0,0 +1,40 @@ +import api from 'themes/glitch/util/api'; + +export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; +export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; +export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; + +import { me } from 'themes/glitch/util/initial_state'; + +export function fetchPinnedStatuses() { + return (dispatch, getState) => { + dispatch(fetchPinnedStatusesRequest()); + + api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { + dispatch(fetchPinnedStatusesSuccess(response.data, null)); + }).catch(error => { + dispatch(fetchPinnedStatusesFail(error)); + }); + }; +}; + +export function fetchPinnedStatusesRequest() { + return { + type: PINNED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchPinnedStatusesSuccess(statuses, next) { + return { + type: PINNED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchPinnedStatusesFail(error) { + return { + type: PINNED_STATUSES_FETCH_FAIL, + error, + }; +}; diff --git a/app/javascript/themes/glitch/actions/push_notifications.js b/app/javascript/themes/glitch/actions/push_notifications.js new file mode 100644 index 000000000..55661d2b0 --- /dev/null +++ b/app/javascript/themes/glitch/actions/push_notifications.js @@ -0,0 +1,52 @@ +import axios from 'axios'; + +export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; + +export function setBrowserSupport (value) { + return { + type: SET_BROWSER_SUPPORT, + value, + }; +} + +export function setSubscription (subscription) { + return { + type: SET_SUBSCRIPTION, + subscription, + }; +} + +export function clearSubscription () { + return { + type: CLEAR_SUBSCRIPTION, + }; +} + +export function changeAlerts(key, value) { + return dispatch => { + dispatch({ + type: ALERTS_CHANGE, + key, + value, + }); + + dispatch(saveSettings()); + }; +} + +export function saveSettings() { + return (_, getState) => { + const state = getState().get('push_notifications'); + const subscription = state.get('subscription'); + const alerts = state.get('alerts'); + + axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { + data: { + alerts, + }, + }); + }; +} diff --git a/app/javascript/themes/glitch/actions/reports.js b/app/javascript/themes/glitch/actions/reports.js new file mode 100644 index 000000000..93f9085b2 --- /dev/null +++ b/app/javascript/themes/glitch/actions/reports.js @@ -0,0 +1,80 @@ +import api from 'themes/glitch/util/api'; +import { openModal, closeModal } from './modal'; + +export const REPORT_INIT = 'REPORT_INIT'; +export const REPORT_CANCEL = 'REPORT_CANCEL'; + +export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; +export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; +export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; + +export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; +export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; + +export function initReport(account, status) { + return dispatch => { + dispatch({ + type: REPORT_INIT, + account, + status, + }); + + dispatch(openModal('REPORT')); + }; +}; + +export function cancelReport() { + return { + type: REPORT_CANCEL, + }; +}; + +export function toggleStatusReport(statusId, checked) { + return { + type: REPORT_STATUS_TOGGLE, + statusId, + checked, + }; +}; + +export function submitReport() { + return (dispatch, getState) => { + dispatch(submitReportRequest()); + + api(getState).post('/api/v1/reports', { + account_id: getState().getIn(['reports', 'new', 'account_id']), + status_ids: getState().getIn(['reports', 'new', 'status_ids']), + comment: getState().getIn(['reports', 'new', 'comment']), + }).then(response => { + dispatch(closeModal()); + dispatch(submitReportSuccess(response.data)); + }).catch(error => dispatch(submitReportFail(error))); + }; +}; + +export function submitReportRequest() { + return { + type: REPORT_SUBMIT_REQUEST, + }; +}; + +export function submitReportSuccess(report) { + return { + type: REPORT_SUBMIT_SUCCESS, + report, + }; +}; + +export function submitReportFail(error) { + return { + type: REPORT_SUBMIT_FAIL, + error, + }; +}; + +export function changeReportComment(comment) { + return { + type: REPORT_COMMENT_CHANGE, + comment, + }; +}; diff --git a/app/javascript/themes/glitch/actions/search.js b/app/javascript/themes/glitch/actions/search.js new file mode 100644 index 000000000..414e4755e --- /dev/null +++ b/app/javascript/themes/glitch/actions/search.js @@ -0,0 +1,73 @@ +import api from 'themes/glitch/util/api'; + +export const SEARCH_CHANGE = 'SEARCH_CHANGE'; +export const SEARCH_CLEAR = 'SEARCH_CLEAR'; +export const SEARCH_SHOW = 'SEARCH_SHOW'; + +export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; + +export function changeSearch(value) { + return { + type: SEARCH_CHANGE, + value, + }; +}; + +export function clearSearch() { + return { + type: SEARCH_CLEAR, + }; +}; + +export function submitSearch() { + return (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + + if (value.length === 0) { + return; + } + + dispatch(fetchSearchRequest()); + + api(getState).get('/api/v1/search', { + params: { + q: value, + resolve: true, + }, + }).then(response => { + dispatch(fetchSearchSuccess(response.data)); + }).catch(error => { + dispatch(fetchSearchFail(error)); + }); + }; +}; + +export function fetchSearchRequest() { + return { + type: SEARCH_FETCH_REQUEST, + }; +}; + +export function fetchSearchSuccess(results) { + return { + type: SEARCH_FETCH_SUCCESS, + results, + accounts: results.accounts, + statuses: results.statuses, + }; +}; + +export function fetchSearchFail(error) { + return { + type: SEARCH_FETCH_FAIL, + error, + }; +}; + +export function showSearch() { + return { + type: SEARCH_SHOW, + }; +}; diff --git a/app/javascript/themes/glitch/actions/settings.js b/app/javascript/themes/glitch/actions/settings.js new file mode 100644 index 000000000..79adca18c --- /dev/null +++ b/app/javascript/themes/glitch/actions/settings.js @@ -0,0 +1,31 @@ +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 => { + dispatch({ + type: SETTING_CHANGE, + key, + value, + }); + + dispatch(saveSettings()); + }; +}; + +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 (dispatch, getState) => debouncedSave(dispatch, getState); +}; diff --git a/app/javascript/themes/glitch/actions/statuses.js b/app/javascript/themes/glitch/actions/statuses.js new file mode 100644 index 000000000..702f4e9b6 --- /dev/null +++ b/app/javascript/themes/glitch/actions/statuses.js @@ -0,0 +1,217 @@ +import api from 'themes/glitch/util/api'; + +import { deleteFromTimelines } from './timelines'; +import { fetchStatusCard } from './cards'; + +export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; +export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; +export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; + +export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; +export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; +export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; + +export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; +export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; +export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; + +export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; +export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; +export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; + +export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; +export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; +export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; + +export function fetchStatusRequest(id, skipLoading) { + return { + type: STATUS_FETCH_REQUEST, + id, + skipLoading, + }; +}; + +export function fetchStatus(id) { + return (dispatch, getState) => { + const skipLoading = getState().getIn(['statuses', id], null) !== null; + + dispatch(fetchContext(id)); + dispatch(fetchStatusCard(id)); + + if (skipLoading) { + return; + } + + dispatch(fetchStatusRequest(id, skipLoading)); + + api(getState).get(`/api/v1/statuses/${id}`).then(response => { + dispatch(fetchStatusSuccess(response.data, skipLoading)); + }).catch(error => { + dispatch(fetchStatusFail(id, error, skipLoading)); + }); + }; +}; + +export function fetchStatusSuccess(status, skipLoading) { + return { + type: STATUS_FETCH_SUCCESS, + status, + skipLoading, + }; +}; + +export function fetchStatusFail(id, error, skipLoading) { + return { + type: STATUS_FETCH_FAIL, + id, + error, + skipLoading, + skipAlert: true, + }; +}; + +export function deleteStatus(id) { + return (dispatch, getState) => { + dispatch(deleteStatusRequest(id)); + + api(getState).delete(`/api/v1/statuses/${id}`).then(() => { + dispatch(deleteStatusSuccess(id)); + dispatch(deleteFromTimelines(id)); + }).catch(error => { + dispatch(deleteStatusFail(id, error)); + }); + }; +}; + +export function deleteStatusRequest(id) { + return { + type: STATUS_DELETE_REQUEST, + id: id, + }; +}; + +export function deleteStatusSuccess(id) { + return { + type: STATUS_DELETE_SUCCESS, + id: id, + }; +}; + +export function deleteStatusFail(id, error) { + return { + type: STATUS_DELETE_FAIL, + id: id, + error: error, + }; +}; + +export function fetchContext(id) { + return (dispatch, getState) => { + dispatch(fetchContextRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { + dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); + + }).catch(error => { + if (error.response && error.response.status === 404) { + dispatch(deleteFromTimelines(id)); + } + + dispatch(fetchContextFail(id, error)); + }); + }; +}; + +export function fetchContextRequest(id) { + return { + type: CONTEXT_FETCH_REQUEST, + id, + }; +}; + +export function fetchContextSuccess(id, ancestors, descendants) { + return { + type: CONTEXT_FETCH_SUCCESS, + id, + ancestors, + descendants, + statuses: ancestors.concat(descendants), + }; +}; + +export function fetchContextFail(id, error) { + return { + type: CONTEXT_FETCH_FAIL, + id, + error, + skipAlert: true, + }; +}; + +export function muteStatus(id) { + return (dispatch, getState) => { + dispatch(muteStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { + dispatch(muteStatusSuccess(id)); + }).catch(error => { + dispatch(muteStatusFail(id, error)); + }); + }; +}; + +export function muteStatusRequest(id) { + return { + type: STATUS_MUTE_REQUEST, + id, + }; +}; + +export function muteStatusSuccess(id) { + return { + type: STATUS_MUTE_SUCCESS, + id, + }; +}; + +export function muteStatusFail(id, error) { + return { + type: STATUS_MUTE_FAIL, + id, + error, + }; +}; + +export function unmuteStatus(id) { + return (dispatch, getState) => { + dispatch(unmuteStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { + dispatch(unmuteStatusSuccess(id)); + }).catch(error => { + dispatch(unmuteStatusFail(id, error)); + }); + }; +}; + +export function unmuteStatusRequest(id) { + return { + type: STATUS_UNMUTE_REQUEST, + id, + }; +}; + +export function unmuteStatusSuccess(id) { + return { + type: STATUS_UNMUTE_SUCCESS, + id, + }; +}; + +export function unmuteStatusFail(id, error) { + return { + type: STATUS_UNMUTE_FAIL, + id, + error, + }; +}; diff --git a/app/javascript/themes/glitch/actions/store.js b/app/javascript/themes/glitch/actions/store.js new file mode 100644 index 000000000..a1db0fdd5 --- /dev/null +++ b/app/javascript/themes/glitch/actions/store.js @@ -0,0 +1,17 @@ +import { Iterable, fromJS } from 'immutable'; + +export const STORE_HYDRATE = 'STORE_HYDRATE'; +export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; + +const convertState = rawState => + fromJS(rawState, (k, v) => + Iterable.isIndexed(v) ? v.toList() : v.toMap()); + +export function hydrateStore(rawState) { + const state = convertState(rawState); + + return { + type: STORE_HYDRATE, + state, + }; +}; diff --git a/app/javascript/themes/glitch/actions/streaming.js b/app/javascript/themes/glitch/actions/streaming.js new file mode 100644 index 000000000..ccf6c27d8 --- /dev/null +++ b/app/javascript/themes/glitch/actions/streaming.js @@ -0,0 +1,54 @@ +import { connectStream } from 'themes/glitch/util/stream'; +import { + updateTimeline, + deleteFromTimelines, + refreshHomeTimeline, + connectTimeline, + disconnectTimeline, +} from './timelines'; +import { updateNotifications, refreshNotifications } from './notifications'; +import { getLocale } from 'mastodon/locales'; + +const { messages } = getLocale(); + +export function connectTimelineStream (timelineId, path, pollingRefresh = null) { + + return connectStream (path, pollingRefresh, (dispatch, getState) => { + const locale = getState().getIn(['meta', 'locale']); + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + }, + + onDisconnect() { + dispatch(disconnectTimeline(timelineId)); + }, + + onReceive (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + break; + } + }, + }; + }); +} + +function refreshHomeTimelineAndNotification (dispatch) { + dispatch(refreshHomeTimeline()); + dispatch(refreshNotifications()); +} + +export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); +export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); +export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); +export const connectPublicStream = () => connectTimelineStream('public', 'public'); +export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); +export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); diff --git a/app/javascript/themes/glitch/actions/timelines.js b/app/javascript/themes/glitch/actions/timelines.js new file mode 100644 index 000000000..5ce14fbe9 --- /dev/null +++ b/app/javascript/themes/glitch/actions/timelines.js @@ -0,0 +1,208 @@ +import api, { getLinks } from 'themes/glitch/util/api'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +export const TIMELINE_DELETE = 'TIMELINE_DELETE'; + +export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST'; +export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS'; +export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL'; + +export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; +export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; +export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; + +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; + +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; + +export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; + +export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { + return { + type: TIMELINE_REFRESH_SUCCESS, + 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, + timeline, + status, + references, + }); + + if (parents.length > 0) { + dispatch({ + type: TIMELINE_CONTEXT_UPDATE, + status, + references: parents, + }); + } + }; +}; + +export function deleteFromTimelines(id) { + return (dispatch, getState) => { + const accountId = getState().getIn(['statuses', id, 'account']); + const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); + + dispatch({ + type: TIMELINE_DELETE, + id, + accountId, + references, + reblogOf, + }); + }; +}; + +export function refreshTimelineRequest(timeline, skipLoading) { + return { + type: TIMELINE_REFRESH_REQUEST, + timeline, + skipLoading, + }; +}; + +export function refreshTimeline(timelineId, path, params = {}) { + return function (dispatch, getState) { + const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); + + if (timeline.get('isLoading') || timeline.get('online')) { + return; + } + + const ids = timeline.get('items', ImmutableList()); + const newestId = ids.size > 0 ? ids.first() : null; + + let skipLoading = timeline.get('loaded'); + + if (newestId !== null) { + params.since_id = newestId; + } + + dispatch(refreshTimelineRequest(timelineId, skipLoading)); + + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null)); + }).catch(error => { + dispatch(refreshTimelineFail(timelineId, error, skipLoading)); + }); + }; +}; + +export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); +export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); +export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); +export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct'); +export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); +export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); +export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); + +export function refreshTimelineFail(timeline, error, skipLoading) { + return { + type: TIMELINE_REFRESH_FAIL, + timeline, + error, + skipLoading, + skipAlert: error.response && error.response.status === 404, + }; +}; + +export function expandTimeline(timelineId, path, params = {}) { + return (dispatch, getState) => { + const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); + const ids = timeline.get('items', ImmutableList()); + + if (timeline.get('isLoading') || ids.size === 0) { + return; + } + + params.max_id = ids.last(); + params.limit = 10; + + dispatch(expandTimelineRequest(timelineId)); + + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandTimelineFail(timelineId, error)); + }); + }; +}; + +export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); +export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); +export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); +export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct'); +export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); +export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); +export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); + +export function expandTimelineRequest(timeline) { + return { + type: TIMELINE_EXPAND_REQUEST, + timeline, + }; +}; + +export function expandTimelineSuccess(timeline, statuses, next) { + return { + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next, + }; +}; + +export function expandTimelineFail(timeline, error) { + return { + type: TIMELINE_EXPAND_FAIL, + timeline, + error, + }; +}; + +export function scrollTopTimeline(timeline, top) { + return { + type: TIMELINE_SCROLL_TOP, + timeline, + top, + }; +}; + +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline, + }; +}; + +export function disconnectTimeline(timeline) { + return { + type: TIMELINE_DISCONNECT, + timeline, + }; +}; diff --git a/app/javascript/themes/glitch/components/account.js b/app/javascript/themes/glitch/components/account.js new file mode 100644 index 000000000..d0ff77050 --- /dev/null +++ b/app/javascript/themes/glitch/components/account.js @@ -0,0 +1,116 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from './avatar'; +import DisplayName from './display_name'; +import Permalink from './permalink'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' }, + unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' }, +}); + +@injectIntl +export default class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hidden: PropTypes.bool, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + handleMuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, true); + } + + handleUnmuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, false); + } + + render () { + const { account, intl, hidden } = this.props; + + if (!account) { + return <div />; + } + + if (hidden) { + return ( + <div> + {account.get('display_name')} + {account.get('username')} + </div> + ); + } + + let buttons; + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; + } else if (blocking) { + buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + } else if (muting) { + let hidingNotificationsButton; + if (muting.get('notifications')) { + hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; + } else { + hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; + } + buttons = ( + <div> + <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} /> + {hidingNotificationsButton} + </div> + ); + } else { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following ? true : false} />; + } + } + + return ( + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__relationship'> + {buttons} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/attachment_list.js b/app/javascript/themes/glitch/components/attachment_list.js new file mode 100644 index 000000000..b3d00b335 --- /dev/null +++ b/app/javascript/themes/glitch/components/attachment_list.js @@ -0,0 +1,33 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; + +export default class AttachmentList extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { media } = this.props; + + return ( + <div className='attachment-list'> + <div className='attachment-list__icon'> + <i className='fa fa-link' /> + </div> + + <ul className='attachment-list__list'> + {media.map(attachment => + <li key={attachment.get('id')}> + <a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a> + </li> + )} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/autosuggest_emoji.js b/app/javascript/themes/glitch/components/autosuggest_emoji.js new file mode 100644 index 000000000..3c6f915e4 --- /dev/null +++ b/app/javascript/themes/glitch/components/autosuggest_emoji.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import unicodeMapping from 'themes/glitch/util/emoji/emoji_unicode_mapping_light'; + +const assetHost = process.env.CDN_HOST || ''; + +export default class AutosuggestEmoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.object.isRequired, + }; + + render () { + const { emoji } = this.props; + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + url = `${assetHost}/emoji/${mapping.filename}.svg`; + } + + return ( + <div className='autosuggest-emoji'> + <img + className='emojione' + src={url} + alt={emoji.native || emoji.colons} + /> + + {emoji.colons} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/autosuggest_textarea.js b/app/javascript/themes/glitch/components/autosuggest_textarea.js new file mode 100644 index 000000000..fa93847a2 --- /dev/null +++ b/app/javascript/themes/glitch/components/autosuggest_textarea.js @@ -0,0 +1,222 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'themes/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'themes/glitch/util/rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Textarea from 'react-textarea-autosize'; +import classNames from 'classnames'; + +const textAtCursorMatchesToken = (str, caretPosition) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left + 1, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestTextarea extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + onPaste: PropTypes.func.isRequired, + autoFocus: PropTypes.bool, + }; + + static defaultProps = { + autoFocus: true, + }; + + state = { + suggestionsHidden: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + } + + onKeyDown = (e) => { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + switch(e.key) { + case 'Escape': + if (!suggestionsHidden) { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + 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 }); + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.textarea.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { + this.setState({ suggestionsHidden: false }); + } + } + + setTextarea = (c) => { + this.textarea = c; + } + + onPaste = (e) => { + if (e.clipboardData && e.clipboardData.files.length === 1) { + this.props.onPaste(e.clipboardData.files); + e.preventDefault(); + } + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else { + inner = <AutosuggestAccountContainer id={suggestion} />; + key = suggestion; + } + + return ( + <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> + {inner} + </div> + ); + } + + render () { + const { value, suggestions, disabled, placeholder, autoFocus } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return ( + <div className='autosuggest-textarea'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <Textarea + inputRef={this.setTextarea} + className='autosuggest-textarea__textarea' + disabled={disabled} + placeholder={placeholder} + autoFocus={autoFocus} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={this.onKeyUp} + onBlur={this.onBlur} + onPaste={this.onPaste} + style={style} + /> + </label> + + <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> + {suggestions.map(this.renderSuggestion)} + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/avatar.js b/app/javascript/themes/glitch/components/avatar.js new file mode 100644 index 000000000..dd155f059 --- /dev/null +++ b/app/javascript/themes/glitch/components/avatar.js @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class Avatar extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + size: PropTypes.number.isRequired, + style: PropTypes.object, + animate: PropTypes.bool, + inline: PropTypes.bool, + }; + + static defaultProps = { + animate: false, + size: 20, + inline: false, + }; + + state = { + hovering: false, + }; + + handleMouseEnter = () => { + if (this.props.animate) return; + this.setState({ hovering: true }); + } + + handleMouseLeave = () => { + if (this.props.animate) return; + this.setState({ hovering: false }); + } + + render () { + const { account, size, animate, inline } = this.props; + const { hovering } = this.state; + + const src = account.get('avatar'); + const staticSrc = account.get('avatar_static'); + + let className = 'account__avatar'; + + if (inline) { + className = className + ' account__avatar-inline'; + } + + const style = { + ...this.props.style, + width: `${size}px`, + height: `${size}px`, + backgroundSize: `${size}px ${size}px`, + }; + + if (hovering || animate) { + style.backgroundImage = `url(${src})`; + } else { + style.backgroundImage = `url(${staticSrc})`; + } + + return ( + <div + className={className} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + style={style} + data-avatar-of={`@${account.get('acct')}`} + /> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/avatar_overlay.js b/app/javascript/themes/glitch/components/avatar_overlay.js new file mode 100644 index 000000000..2ecf9fa44 --- /dev/null +++ b/app/javascript/themes/glitch/components/avatar_overlay.js @@ -0,0 +1,30 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class AvatarOverlay extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map.isRequired, + }; + + render() { + const { account, friend } = this.props; + + const baseStyle = { + backgroundImage: `url(${account.get('avatar_static')})`, + }; + + const overlayStyle = { + backgroundImage: `url(${friend.get('avatar_static')})`, + }; + + return ( + <div className='account__avatar-overlay'> + <div className='account__avatar-overlay-base' style={baseStyle} data-avatar-of={`@${account.get('acct')}`} /> + <div className='account__avatar-overlay-overlay' style={overlayStyle} data-avatar-of={`@${friend.get('acct')}`} /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/button.js b/app/javascript/themes/glitch/components/button.js new file mode 100644 index 000000000..16868010c --- /dev/null +++ b/app/javascript/themes/glitch/components/button.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class Button extends React.PureComponent { + + static propTypes = { + text: PropTypes.node, + onClick: PropTypes.func, + disabled: PropTypes.bool, + block: PropTypes.bool, + secondary: PropTypes.bool, + size: PropTypes.number, + className: PropTypes.string, + style: PropTypes.object, + children: PropTypes.node, + title: PropTypes.string, + }; + + static defaultProps = { + size: 36, + }; + + handleClick = (e) => { + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + setRef = (c) => { + this.node = c; + } + + focus() { + this.node.focus(); + } + + render () { + let attrs = { + className: classNames('button', this.props.className, { + 'button-secondary': this.props.secondary, + 'button--block': this.props.block, + }), + disabled: this.props.disabled, + onClick: this.handleClick, + ref: this.setRef, + style: { + padding: `0 ${this.props.size / 2.25}px`, + height: `${this.props.size}px`, + lineHeight: `${this.props.size}px`, + ...this.props.style, + }, + }; + + if (this.props.title) attrs.title = this.props.title; + + return ( + <button {...attrs}> + {this.props.text || this.props.children} + </button> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/collapsable.js b/app/javascript/themes/glitch/components/collapsable.js new file mode 100644 index 000000000..8bc0a54f4 --- /dev/null +++ b/app/javascript/themes/glitch/components/collapsable.js @@ -0,0 +1,22 @@ +import React from 'react'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import PropTypes from 'prop-types'; + +const Collapsable = ({ fullHeight, isVisible, children }) => ( + <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}> + {({ opacity, height }) => + <div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}> + {children} + </div> + } + </Motion> +); + +Collapsable.propTypes = { + fullHeight: PropTypes.number.isRequired, + isVisible: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, +}; + +export default Collapsable; diff --git a/app/javascript/themes/glitch/components/column.js b/app/javascript/themes/glitch/components/column.js new file mode 100644 index 000000000..adeba9cc1 --- /dev/null +++ b/app/javascript/themes/glitch/components/column.js @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import detectPassiveEvents from 'detect-passive-events'; +import { scrollTop } from 'themes/glitch/util/scroll'; + +export default class Column extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + extraClasses: PropTypes.string, + name: PropTypes.string, + }; + + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = c => { + this.node = c; + } + + componentDidMount () { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + + componentWillUnmount () { + this.node.removeEventListener('wheel', this.handleWheel); + } + + render () { + const { children, extraClasses, name } = this.props; + + return ( + <div role='region' data-column={name} className={`column ${extraClasses || ''}`} ref={this.setRef}> + {children} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/column_back_button.js b/app/javascript/themes/glitch/components/column_back_button.js new file mode 100644 index 000000000..50c3bf11f --- /dev/null +++ b/app/javascript/themes/glitch/components/column_back_button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class ColumnBackButton extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + handleClick = () => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } + } + + render () { + return ( + <button onClick={this.handleClick} className='column-back-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/column_back_button_slim.js b/app/javascript/themes/glitch/components/column_back_button_slim.js new file mode 100644 index 000000000..2cdf1b25b --- /dev/null +++ b/app/javascript/themes/glitch/components/column_back_button_slim.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class ColumnBackButtonSlim extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + handleClick = () => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } + } + + render () { + return ( + <div className='column-back-button--slim'> + <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/column_header.js b/app/javascript/themes/glitch/components/column_header.js new file mode 100644 index 000000000..e601082c8 --- /dev/null +++ b/app/javascript/themes/glitch/components/column_header.js @@ -0,0 +1,214 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Glitch imports +import NotificationPurgeButtonsContainer from 'themes/glitch/containers/notification_purge_buttons_container'; + +const messages = defineMessages({ + show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, + hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, + moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, + moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, + enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, +}); + +@injectIntl +export default class ColumnHeader extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + title: PropTypes.node.isRequired, + icon: PropTypes.string.isRequired, + active: PropTypes.bool, + localSettings : ImmutablePropTypes.map, + multiColumn: PropTypes.bool, + focusable: PropTypes.bool, + showBackButton: PropTypes.bool, + notifCleaning: PropTypes.bool, // true only for the notification column + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, + children: PropTypes.node, + pinned: PropTypes.bool, + onPin: PropTypes.func, + onMove: PropTypes.func, + onClick: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + static defaultProps = { + focusable: true, + } + + state = { + collapsed: true, + animating: false, + animatingNCD: false, + }; + + handleToggleClick = (e) => { + e.stopPropagation(); + this.setState({ collapsed: !this.state.collapsed, animating: true }); + } + + handleTitleClick = () => { + this.props.onClick(); + } + + handleMoveLeft = () => { + this.props.onMove(-1); + } + + handleMoveRight = () => { + this.props.onMove(1); + } + + handleBackClick = () => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } + } + + handleTransitionEnd = () => { + this.setState({ animating: false }); + } + + handleTransitionEndNCD = () => { + this.setState({ animatingNCD: false }); + } + + onEnterCleaningMode = () => { + this.setState({ animatingNCD: true }); + this.props.onEnterCleaningMode(!this.props.notifCleaningActive); + } + + render () { + const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props; + const { collapsed, animating, animatingNCD } = this.state; + + let title = this.props.title; + + const wrapperClassName = classNames('column-header__wrapper', { + 'active': active, + }); + + const buttonClassName = classNames('column-header', { + 'active': active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + 'collapsed': collapsed, + 'animating': animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + 'active': !collapsed, + }); + + const notifCleaningButtonClassName = classNames('column-header__button', { + 'active': notifCleaningActive, + }); + + const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', { + 'collapsed': !notifCleaningActive, + 'animating': animatingNCD, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + //*glitch + const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); + + if (children) { + extraContent = ( + <div key='extra-content' className='column-header__collapsible__extra'> + {children} + </div> + ); + } + + if (multiColumn && pinned) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; + + moveButtons = ( + <div key='move-buttons' className='column-header__setting-arrows'> + <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button> + <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button> + </div> + ); + } else if (multiColumn) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; + } + + if (!pinned && (multiColumn || showBackButton)) { + backButton = ( + <button onClick={this.handleBackClick} className='column-header__back-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(moveButtons); + collapsedContent.push(pinButton); + } + + if (children || multiColumn) { + collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; + } + + return ( + <div className={wrapperClassName}> + <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`} /> + <span className='column-header__title'> + {title} + </span> + <div className='column-header__buttons'> + {backButton} + { notifCleaning ? ( + <button + aria-label={msgEnterNotifCleaning} + title={msgEnterNotifCleaning} + onClick={this.onEnterCleaningMode} + className={notifCleaningButtonClassName} + > + <i className='fa fa-eraser' /> + </button> + ) : null} + {collapseButton} + </div> + </h1> + + { notifCleaning ? ( + <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}> + <div className='column-header__collapsible-inner nopad-drawer'> + {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null } + </div> + </div> + ) : null} + + <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> + <div className='column-header__collapsible-inner'> + {(!collapsed || animating) && collapsedContent} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/display_name.js b/app/javascript/themes/glitch/components/display_name.js new file mode 100644 index 000000000..2cf84f8f4 --- /dev/null +++ b/app/javascript/themes/glitch/components/display_name.js @@ -0,0 +1,20 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class DisplayName extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const displayNameHtml = { __html: this.props.account.get('display_name_html') }; + + return ( + <span className='display-name'> + <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> + </span> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/dropdown_menu.js b/app/javascript/themes/glitch/components/dropdown_menu.js new file mode 100644 index 000000000..d30dc2aaf --- /dev/null +++ b/app/javascript/themes/glitch/components/dropdown_menu.js @@ -0,0 +1,211 @@ +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/lib/Overlay'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import detectPassiveEvents from 'detect-passive-events'; + +const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +class DropdownMenu extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + items: PropTypes.array.isRequired, + onClose: PropTypes.func.isRequired, + style: PropTypes.object, + placement: PropTypes.string, + arrowOffsetLeft: PropTypes.string, + arrowOffsetTop: PropTypes.string, + }; + + static defaultProps = { + style: {}, + placement: 'bottom', + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + 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; + } + + handleClick = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const { action, to } = this.props.items[i]; + + this.props.onClose(); + + if (typeof action === 'function') { + e.preventDefault(); + action(); + } else if (to) { + e.preventDefault(); + this.context.router.history.push(to); + } + } + + renderItem (option, i) { + if (option === null) { + return <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { text, href = '#' } = option; + + return ( + <li className='dropdown-menu__item' key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}> + {text} + </a> + </li> + ); + } + + render () { + const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = 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='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> + <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> + + <ul> + {items.map((option, i) => this.renderItem(option, i))} + </ul> + </div> + )} + </Motion> + ); + } + +} + +export default class Dropdown extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + icon: PropTypes.string.isRequired, + items: PropTypes.array.isRequired, + size: PropTypes.number.isRequired, + ariaLabel: PropTypes.string, + disabled: PropTypes.bool, + status: ImmutablePropTypes.map, + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + }; + + static defaultProps = { + ariaLabel: 'Menu', + }; + + state = { + expanded: false, + }; + + handleClick = () => { + if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) { + const { status, items } = this.props; + + this.props.onModalOpen({ + status, + actions: items, + onClick: this.handleItemClick, + }); + + return; + } + + this.setState({ expanded: !this.state.expanded }); + } + + handleClose = () => { + if (this.props.onModalClose) { + this.props.onModalClose(); + } + + this.setState({ expanded: false }); + } + + handleKeyDown = e => { + switch(e.key) { + case 'Enter': + this.handleClick(); + break; + case 'Escape': + this.handleClose(); + break; + } + } + + handleItemClick = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const { action, to } = this.props.items[i]; + + this.handleClose(); + + if (typeof action === 'function') { + e.preventDefault(); + action(); + } else if (to) { + e.preventDefault(); + this.context.router.history.push(to); + } + } + + setTargetRef = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + render () { + const { icon, items, size, ariaLabel, disabled } = this.props; + const { expanded } = this.state; + + return ( + <div onKeyDown={this.handleKeyDown}> + <IconButton + icon={icon} + title={ariaLabel} + active={expanded} + disabled={disabled} + size={size} + ref={this.setTargetRef} + onClick={this.handleClick} + /> + + <Overlay show={expanded} placement='bottom' target={this.findTarget}> + <DropdownMenu items={items} onClose={this.handleClose} /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/extended_video_player.js b/app/javascript/themes/glitch/components/extended_video_player.js new file mode 100644 index 000000000..f8bd067e8 --- /dev/null +++ b/app/javascript/themes/glitch/components/extended_video_player.js @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +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, + controls: PropTypes.bool.isRequired, + muted: PropTypes.bool.isRequired, + }; + + handleLoadedData = () => { + if (this.props.time) { + this.video.currentTime = this.props.time; + } + } + + componentDidMount () { + this.video.addEventListener('loadeddata', this.handleLoadedData); + } + + componentWillUnmount () { + this.video.removeEventListener('loadeddata', this.handleLoadedData); + } + + setRef = (c) => { + this.video = c; + } + + render () { + const { src, muted, controls, alt } = this.props; + + return ( + <div className='extended-video-player'> + <video + ref={this.setRef} + src={src} + autoPlay + role='button' + tabIndex='0' + aria-label={alt} + muted={muted} + controls={controls} + loop={!controls} + /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/icon_button.js b/app/javascript/themes/glitch/components/icon_button.js new file mode 100644 index 000000000..31cdf4703 --- /dev/null +++ b/app/javascript/themes/glitch/components/icon_button.js @@ -0,0 +1,137 @@ +import React from 'react'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class IconButton extends React.PureComponent { + + static propTypes = { + className: PropTypes.string, + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + onClick: PropTypes.func, + size: PropTypes.number, + active: PropTypes.bool, + pressed: PropTypes.bool, + expanded: PropTypes.bool, + style: PropTypes.object, + activeStyle: PropTypes.object, + disabled: PropTypes.bool, + inverted: PropTypes.bool, + animate: PropTypes.bool, + flip: PropTypes.bool, + overlay: PropTypes.bool, + tabIndex: PropTypes.string, + label: PropTypes.string, + }; + + static defaultProps = { + size: 18, + active: false, + disabled: false, + animate: false, + overlay: false, + tabIndex: '0', + }; + + handleClick = (e) => { + e.preventDefault(); + + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + render () { + let style = { + fontSize: `${this.props.size}px`, + height: `${this.props.size * 1.28571429}px`, + lineHeight: `${this.props.size}px`, + ...this.props.style, + ...(this.props.active ? this.props.activeStyle : {}), + }; + if (!this.props.label) { + style.width = `${this.props.size * 1.28571429}px`; + } else { + style.textAlign = 'left'; + } + + const { + active, + animate, + className, + disabled, + expanded, + icon, + inverted, + flip, + overlay, + pressed, + tabIndex, + title, + } = this.props; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + inverted, + overlayed: overlay, + }); + + const flipDeg = flip ? -180 : -360; + const rotateDeg = active ? flipDeg : 0; + + const motionDefaultStyle = { + rotate: rotateDeg, + }; + + const springOpts = { + stiffness: this.props.flip ? 60 : 120, + damping: 7, + }; + const motionStyle = { + rotate: animate ? spring(rotateDeg, springOpts) : 0, + }; + + if (!animate) { + // Perf optimization: avoid unnecessary <Motion> components unless + // we actually need to animate. + return ( + <button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + style={style} + tabIndex={tabIndex} + > + <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> + </button> + ); + } + + return ( + <Motion defaultStyle={motionDefaultStyle} style={motionStyle}> + {({ rotate }) => + <button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + style={style} + tabIndex={tabIndex} + > + <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> + {this.props.label} + </button> + } + </Motion> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/intersection_observer_article.js b/app/javascript/themes/glitch/components/intersection_observer_article.js new file mode 100644 index 000000000..f0139ac75 --- /dev/null +++ b/app/javascript/themes/glitch/components/intersection_observer_article.js @@ -0,0 +1,130 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import scheduleIdleTask from 'themes/glitch/util/schedule_idle_task'; +import getRectFromEntry from 'themes/glitch/util/get_rect_from_entry'; +import { is } from 'immutable'; + +// Diff these props in the "rendered" state +const updateOnPropsForRendered = ['id', 'index', 'listLength']; +// Diff these props in the "unrendered" state +const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; + +export default class IntersectionObserverArticle extends React.Component { + + static propTypes = { + intersectionObserverWrapper: PropTypes.object.isRequired, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + saveHeightKey: PropTypes.string, + cachedHeight: PropTypes.number, + onHeightChange: PropTypes.func, + children: PropTypes.node, + }; + + state = { + isHidden: false, // set to true in requestIdleCallback to trigger un-render + } + + shouldComponentUpdate (nextProps, nextState) { + const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); + const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); + if (!!isUnrendered !== !!willBeUnrendered) { + // If we're going from rendered to unrendered (or vice versa) then update + return true; + } + // Otherwise, diff based on props + const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered; + return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop])); + } + + componentDidMount () { + const { intersectionObserverWrapper, id } = this.props; + + intersectionObserverWrapper.observe( + id, + this.node, + this.handleIntersection + ); + + this.componentMounted = true; + } + + componentWillUnmount () { + const { intersectionObserverWrapper, id } = this.props; + intersectionObserverWrapper.unobserve(id, this.node); + + this.componentMounted = false; + } + + handleIntersection = (entry) => { + this.entry = entry; + + scheduleIdleTask(this.calculateHeight); + this.setState(this.updateStateAfterIntersection); + } + + updateStateAfterIntersection = (prevState) => { + if (prevState.isIntersecting && !this.entry.isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting: this.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 = () => { + if (!this.componentMounted) { + return; + } + + // When the browser gets a chance, test if we're still not intersecting, + // and if so, set our isHidden to true to trigger an unrender. The point of + // this is to save DOM nodes and avoid using up too much memory. + // See: https://github.com/tootsuite/mastodon/issues/2900 + this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); + } + + handleRef = (node) => { + this.node = node; + } + + render () { + const { children, id, index, listLength, cachedHeight } = this.props; + const { isIntersecting, isHidden } = this.state; + + if (!isIntersecting && (isHidden || cachedHeight)) { + return ( + <article + ref={this.handleRef} + aria-posinset={index} + aria-setsize={listLength} + style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }} + data-id={id} + tabIndex='0' + > + {children && React.cloneElement(children, { hidden: true })} + </article> + ); + } + + return ( + <article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'> + {children && React.cloneElement(children, { hidden: false })} + </article> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/load_more.js b/app/javascript/themes/glitch/components/load_more.js new file mode 100644 index 000000000..c4c8c94a2 --- /dev/null +++ b/app/javascript/themes/glitch/components/load_more.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadMore extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + visible: PropTypes.bool, + } + + static defaultProps = { + visible: true, + } + + render() { + const { visible } = this.props; + + return ( + <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> + <FormattedMessage id='status.load_more' defaultMessage='Load more' /> + </button> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/loading_indicator.js b/app/javascript/themes/glitch/components/loading_indicator.js new file mode 100644 index 000000000..d6a5adb6f --- /dev/null +++ b/app/javascript/themes/glitch/components/loading_indicator.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +const LoadingIndicator = () => ( + <div className='loading-indicator'> + <div className='loading-indicator__figure' /> + <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> + </div> +); + +export default LoadingIndicator; diff --git a/app/javascript/themes/glitch/components/media_gallery.js b/app/javascript/themes/glitch/components/media_gallery.js new file mode 100644 index 000000000..b6b40c585 --- /dev/null +++ b/app/javascript/themes/glitch/components/media_gallery.js @@ -0,0 +1,255 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { is } from 'immutable'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { isIOS } from 'themes/glitch/util/is_mobile'; +import classNames from 'classnames'; +import { autoPlayGif } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, +}); + +class Item extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + standalone: PropTypes.bool, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + letterbox: PropTypes.bool, + onClick: PropTypes.func.isRequired, + }; + + static defaultProps = { + standalone: false, + index: 0, + size: 1, + }; + + handleMouseEnter = (e) => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = (e) => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + hoverToPlay () { + const { attachment } = this.props; + return !autoPlayGif && attachment.get('type') === 'gifv'; + } + + handleClick = (e) => { + const { index, onClick } = this.props; + + if (this.context.router && e.button === 0) { + e.preventDefault(); + onClick(index); + } + + e.stopPropagation(); + } + + render () { + const { attachment, index, size, standalone, letterbox } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (size === 2) { + if (index === 0) { + right = '2px'; + } else { + left = '2px'; + } + } else if (size === 3) { + if (index === 0) { + right = '2px'; + } else if (index > 0) { + left = '2px'; + } + + if (index === 1) { + bottom = '2px'; + } else if (index > 1) { + top = '2px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '2px'; + } + + if (index === 1 || index === 3) { + left = '2px'; + } + + if (index < 2) { + bottom = '2px'; + } else { + top = '2px'; + } + } + + let thumbnail = ''; + + if (attachment.get('type') === 'image') { + const previewUrl = attachment.get('preview_url'); + const previewWidth = attachment.getIn(['meta', 'small', 'width']); + + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); + + const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; + + const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; + const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; + + thumbnail = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || originalUrl} + onClick={this.handleClick} + target='_blank' + > + <img className={letterbox ? 'letterbox' : null} src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> + </a> + ); + } else if (attachment.get('type') === 'gifv') { + const autoPlay = !isIOS() && autoPlayGif; + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`} + aria-label={attachment.get('description')} + role='application' + src={attachment.get('url')} + onClick={this.handleClick} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> + + <span className='media-gallery__gifv__label'>GIF</span> + </div> + ); + } + + return ( + <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + {thumbnail} + </div> + ); + } + +} + +@injectIntl +export default class MediaGallery extends React.PureComponent { + + static propTypes = { + sensitive: PropTypes.bool, + standalone: PropTypes.bool, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + size: PropTypes.object, + onOpenMedia: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + static defaultProps = { + standalone: false, + }; + + state = { + visible: !this.props.sensitive, + }; + + componentWillReceiveProps (nextProps) { + if (!is(nextProps.media, this.props.media)) { + this.setState({ visible: !nextProps.sensitive }); + } + } + + handleOpen = () => { + this.setState({ visible: !this.state.visible }); + } + + handleClick = (index) => { + this.props.onOpenMedia(this.props.media, index); + } + + isStandaloneEligible() { + const { media, standalone } = this.props; + return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + } + + render () { + const { media, intl, sensitive, letterbox, fullwidth } = this.props; + const { visible } = this.state; + const size = media.take(4).size; + + let children; + + if (!visible) { + let warning; + + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + children = ( + <button className='media-spoiler' onClick={this.handleOpen}> + <span className='media-spoiler__warning'>{warning}</span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </button> + ); + } else { + if (this.isStandaloneEligible()) { + children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} />); + } + } + + return ( + <div className={`media-gallery size-${size} ${fullwidth ? 'full-width' : ''}`}> + <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} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/missing_indicator.js b/app/javascript/themes/glitch/components/missing_indicator.js new file mode 100644 index 000000000..87df7f61c --- /dev/null +++ b/app/javascript/themes/glitch/components/missing_indicator.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +const MissingIndicator = () => ( + <div className='missing-indicator'> + <div> + <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' /> + </div> + </div> +); + +export default MissingIndicator; diff --git a/app/javascript/themes/glitch/components/notification_purge_buttons.js b/app/javascript/themes/glitch/components/notification_purge_buttons.js new file mode 100644 index 000000000..e0c1543b0 --- /dev/null +++ b/app/javascript/themes/glitch/components/notification_purge_buttons.js @@ -0,0 +1,58 @@ +/** + * Buttons widget for controlling the notification clearing mode. + * In idle state, the cleaning mode button is shown. When the mode is active, + * a Confirm and Abort buttons are shown in its place. + */ + + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' }, + btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' }, + btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' }, + btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' }, +}); + +@injectIntl +export default class NotificationPurgeButtons extends ImmutablePureComponent { + + static propTypes = { + onDeleteMarked : PropTypes.func.isRequired, + onMarkAll : PropTypes.func.isRequired, + onMarkNone : PropTypes.func.isRequired, + onInvert : PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + markNewForDelete: PropTypes.bool, + }; + + render () { + const { intl, markNewForDelete } = this.props; + + //className='active' + return ( + <div className='column-header__notif-cleaning-buttons'> + <button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}> + <b>∀</b><br />{intl.formatMessage(messages.btnAll)} + </button> + + <button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}> + <b>∅</b><br />{intl.formatMessage(messages.btnNone)} + </button> + + <button onClick={this.props.onInvert}> + <b>¬</b><br />{intl.formatMessage(messages.btnInvert)} + </button> + + <button onClick={this.props.onDeleteMarked}> + <i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)} + </button> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/permalink.js b/app/javascript/themes/glitch/components/permalink.js new file mode 100644 index 000000000..d726d37a2 --- /dev/null +++ b/app/javascript/themes/glitch/components/permalink.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class Permalink extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + className: PropTypes.string, + href: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + children: PropTypes.node, + }; + + handleClick = (e) => { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(this.props.to); + } + } + + render () { + const { href, children, className, ...other } = this.props; + + return ( + <a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> + {children} + </a> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/relative_timestamp.js b/app/javascript/themes/glitch/components/relative_timestamp.js new file mode 100644 index 000000000..51588e78c --- /dev/null +++ b/app/javascript/themes/glitch/components/relative_timestamp.js @@ -0,0 +1,147 @@ +import React from 'react'; +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', + month: 'short', + day: '2-digit', + hour: '2-digit', + 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 { + + static propTypes = { + intl: PropTypes.object.isRequired, + timestamp: PropTypes.string.isRequired, + }; + + 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.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); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _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 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)}> + {relativeTime} + </time> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/scrollable_list.js b/app/javascript/themes/glitch/components/scrollable_list.js new file mode 100644 index 000000000..ccdcd7c85 --- /dev/null +++ b/app/javascript/themes/glitch/components/scrollable_list.js @@ -0,0 +1,198 @@ +import React, { PureComponent } from 'react'; +import { ScrollContainer } from 'react-router-scroll-4'; +import PropTypes from 'prop-types'; +import IntersectionObserverArticleContainer from 'themes/glitch/containers/intersection_observer_article_container'; +import LoadMore from './load_more'; +import IntersectionObserverWrapper from 'themes/glitch/util/intersection_observer_wrapper'; +import { throttle } from 'lodash'; +import { List as ImmutableList } from 'immutable'; +import classNames from 'classnames'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'themes/glitch/util/fullscreen'; + +export default class ScrollableList extends PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + onScrollToBottom: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + emptyMessage: PropTypes.node, + children: PropTypes.node, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + lastMouseMove: null, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const { scrollTop, scrollHeight, clientHeight } = this.node; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { + this.props.onScrollToBottom(); + } else if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + } + }, 150, { + trailing: true, + }); + + handleMouseMove = throttle(() => { + this._lastMouseMove = new Date(); + }, 300); + + handleMouseLeave = () => { + this._lastMouseMove = null; + } + + componentDidMount () { + this.attachScrollListener(); + this.attachIntersectionObserver(); + attachFullscreenListener(this.onFullScreenChange); + + // Handle initial scroll posiiton + this.handleScroll(); + } + + componentDidUpdate (prevProps) { + const someItemInserted = React.Children.count(prevProps.children) > 0 && + React.Children.count(prevProps.children) < React.Children.count(this.props.children) && + this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); + + // Reset the scroll position when a new child comes in in order not to + // jerk the scrollbar around if you're already scrolled down the page. + if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) { + const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; + + if (this.node.scrollTop !== newScrollTop) { + this.node.scrollTop = newScrollTop; + } + } else { + this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; + } + } + + componentWillUnmount () { + this.detachScrollListener(); + this.detachIntersectionObserver(); + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + attachIntersectionObserver () { + this.intersectionObserverWrapper.connect({ + root: this.node, + rootMargin: '300% 0px', + }); + } + + detachIntersectionObserver () { + this.intersectionObserverWrapper.disconnect(); + } + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + } + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + } + + getFirstChildKey (props) { + const { children } = props; + let firstChild = children; + if (children instanceof ImmutableList) { + firstChild = children.get(0); + } else if (Array.isArray(children)) { + firstChild = children[0]; + } + return firstChild && firstChild.key; + } + + setRef = (c) => { + this.node = c; + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.onScrollToBottom(); + } + + _recentlyMoved () { + return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); + } + + 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; + let scrollableArea = null; + + if (isLoading || childrenCount > 0 || !emptyMessage) { + scrollableArea = ( + <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) => ( + <IntersectionObserverArticleContainer + key={child.key} + id={child.key} + index={index} + listLength={childrenCount} + intersectionObserverWrapper={this.intersectionObserverWrapper} + saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} + > + {child} + </IntersectionObserverArticleContainer> + ))} + + {loadMore} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + {emptyMessage} + </div> + ); + } + + if (trackScroll) { + return ( + <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> + {scrollableArea} + </ScrollContainer> + ); + } else { + return scrollableArea; + } + } + +} diff --git a/app/javascript/themes/glitch/components/setting_text.js b/app/javascript/themes/glitch/components/setting_text.js new file mode 100644 index 000000000..a6dde4c0f --- /dev/null +++ b/app/javascript/themes/glitch/components/setting_text.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class SettingText extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingKey: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = (e) => { + this.props.onChange(this.props.settingKey, e.target.value); + } + + render () { + const { settings, settingKey, label } = this.props; + + return ( + <label> + <span style={{ display: 'none' }}>{label}</span> + <input + className='setting-text' + value={settings.getIn(settingKey)} + onChange={this.handleChange} + placeholder={label} + /> + </label> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/status.js b/app/javascript/themes/glitch/components/status.js new file mode 100644 index 000000000..e2ef47f5f --- /dev/null +++ b/app/javascript/themes/glitch/components/status.js @@ -0,0 +1,442 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import StatusPrepend from './status_prepend'; +import StatusHeader from './status_header'; +import StatusContent from './status_content'; +import StatusActionBar from './status_action_bar'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { MediaGallery, Video } from 'themes/glitch/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import NotificationOverlayContainer from 'themes/glitch/features/notifications/containers/overlay_container'; + +// We use the component (and not the container) since we do not want +// to use the progress bar to show download progress +import Bundle from '../features/ui/components/bundle'; + +export default class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + id: PropTypes.string, + status: ImmutablePropTypes.map, + account: ImmutablePropTypes.map, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onPin: PropTypes.func, + onOpenMedia: PropTypes.func, + onOpenVideo: PropTypes.func, + onBlock: PropTypes.func, + onEmbed: PropTypes.func, + onHeightChange: PropTypes.func, + muted: PropTypes.bool, + collapse: PropTypes.bool, + hidden: PropTypes.bool, + prepend: PropTypes.string, + withDismiss: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + }; + + state = { + isExpanded: null, + markedForDelete: false, + } + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'account', + 'settings', + 'prepend', + 'boostModal', + 'muted', + 'collapse', + 'notification', + ] + + updateOnStates = [ + 'isExpanded', + 'markedForDelete', + ] + + // If our settings have changed to disable collapsed statuses, then we + // need to make sure that we uncollapse every one. We do that by watching + // for changes to `settings.collapsed.enabled` in + // `componentWillReceiveProps()`. + + // We also need to watch for changes on the `collapse` prop---if this + // changes to anything other than `undefined`, then we need to collapse or + // uncollapse our status accordingly. + componentWillReceiveProps (nextProps) { + if (!nextProps.settings.getIn(['collapsed', 'enabled'])) { + if (this.state.isExpanded === false) { + this.setExpansion(null); + } + } else if ( + nextProps.collapse !== this.props.collapse && + nextProps.collapse !== undefined + ) this.setExpansion(nextProps.collapse ? false : null); + } + + // When mounting, we just check to see if our status should be collapsed, + // and collapse it if so. We don't need to worry about whether collapsing + // is enabled here, because `setExpansion()` already takes that into + // account. + + // The cases where a status should be collapsed are: + // + // - The `collapse` prop has been set to `true` + // - The user has decided in local settings to collapse all statuses. + // - The user has decided to collapse all notifications ('muted' + // statuses). + // - The user has decided to collapse long statuses and the status is + // over 400px (without media, or 650px with). + // - The status is a reply and the user has decided to collapse all + // replies. + // - The status contains media and the user has decided to collapse all + // statuses with media. + // - The status is a reblog the user has decided to collapse all + // statuses which are reblogs. + componentDidMount () { + const { node } = this; + const { + status, + settings, + collapse, + muted, + prepend, + } = this.props; + const autoCollapseSettings = settings.getIn(['collapsed', 'auto']); + + if (function () { + switch (true) { + case collapse: + case autoCollapseSettings.get('all'): + case autoCollapseSettings.get('notifications') && muted: + case autoCollapseSettings.get('lengthy') && node.clientHeight > ( + status.get('media_attachments').size && !muted ? 650 : 400 + ): + case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by': + case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null: + case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size: + return true; + default: + return false; + } + }()) this.setExpansion(false); + } + + // `setExpansion()` sets the value of `isExpanded` in our state. It takes + // one argument, `value`, which gives the desired value for `isExpanded`. + // The default for this argument is `null`. + + // `setExpansion()` automatically checks for us whether toot collapsing + // is enabled, so we don't have to. + setExpansion = (value) => { + switch (true) { + case value === undefined || value === null: + this.setState({ isExpanded: null }); + break; + case !value && this.props.settings.getIn(['collapsed', 'enabled']): + this.setState({ isExpanded: false }); + break; + case !!value: + this.setState({ isExpanded: true }); + break; + } + } + + // `parseClick()` takes a click event and responds appropriately. + // If our status is collapsed, then clicking on it should uncollapse it. + // If `Shift` is held, then clicking on it should collapse it. + // Otherwise, we open the url handed to us in `destination`, if + // applicable. + parseClick = (e, destination) => { + const { router } = this.context; + const { status } = this.props; + const { isExpanded } = this.state; + if (!router) return; + if (destination === undefined) { + destination = `/statuses/${ + status.getIn(['reblog', 'id'], status.get('id')) + }`; + } + if (e.button === 0) { + if (isExpanded === false) this.setExpansion(null); + else if (e.shiftKey) { + this.setExpansion(false); + document.getSelection().removeAllRanges(); + } else router.history.push(destination); + e.preventDefault(); + } + } + + handleAccountClick = (e) => { + if (this.context.router && e.button === 0) { + const id = e.currentTarget.getAttribute('data-id'); + e.preventDefault(); + this.context.router.history.push(`/accounts/${id}`); + } + } + + handleExpandedToggle = () => { + this.setExpansion(this.state.isExpanded || !this.props.status.get('spoiler') ? null : true); + }; + + handleOpenVideo = startTime => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this.props.status, this.context.router.history); + } + + handleHotkeyFavourite = () => { + this.props.onFavourite(this.props.status); + } + + handleHotkeyBoost = e => { + this.props.onReblog(this.props.status, e); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleHotkeyOpen = () => { + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + + handleHotkeyOpenProfile = () => { + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + + handleHotkeyMoveUp = () => { + this.props.onMoveUp(this.props.status.get('id')); + } + + handleHotkeyMoveDown = () => { + this.props.onMoveDown(this.props.status.get('id')); + } + + handleRef = c => { + this.node = c; + } + + renderLoadingMediaGallery () { + return <div className='media_gallery' style={{ height: '110px' }} />; + } + + renderLoadingVideoPlayer () { + return <div className='media-spoiler-video' style={{ height: '110px' }} />; + } + + render () { + const { + handleRef, + parseClick, + setExpansion, + } = this; + const { router } = this.context; + const { + status, + account, + settings, + collapsed, + muted, + prepend, + intersectionObserverWrapper, + onOpenVideo, + onOpenMedia, + notification, + hidden, + ...other + } = this.props; + const { isExpanded } = this.state; + let background = null; + let attachments = null; + let media = null; + let mediaIcon = null; + + if (status === null) { + return null; + } + + if (hidden) { + return ( + <div + ref={this.handleRef} + data-id={status.get('id')} + style={{ + height: `${this.height}px`, + opacity: 0, + overflow: 'hidden', + }} + > + {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {' '} + {status.get('content')} + </div> + ); + } + + // If user backgrounds for collapsed statuses are enabled, then we + // initialize our background accordingly. This will only be rendered if + // the status is collapsed. + if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) { + background = status.getIn(['account', 'header']); + } + + // This handles our media attachments. Note that we don't show media on + // muted (notification) statuses. If the media type is unknown, then we + // simply ignore it. + + // After we have generated our appropriate media element and stored it in + // `media`, we snatch the thumbnail to use as our `background` if media + // backgrounds for collapsed statuses are enabled. + attachments = status.get('media_attachments'); + if (attachments.size > 0 && !muted) { + if (attachments.some(item => item.get('type') === 'unknown')) { // Media type is 'unknown' + /* Do nothing */ + } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video' + const video = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > + {Component => <Component + preview={video.get('preview_url')} + src={video.get('url')} + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + onOpenVideo={this.handleOpenVideo} + />} + </Bundle> + ); + mediaIcon = 'video-camera'; + } else { // Media type is 'image' or 'gifv' + media = ( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > + {Component => ( + <Component + media={attachments} + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + onOpenMedia={this.props.onOpenMedia} + /> + )} + </Bundle> + ); + mediaIcon = 'picture-o'; + } + + if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) { + background = attachments.getIn([0, 'preview_url']); + } + } + + // Here we prepare extra data-* attributes for CSS selectors. + // Users can use those for theming, hiding avatars etc via UserStyle + const selectorAttribs = { + 'data-status-by': `@${status.getIn(['account', 'acct'])}`, + }; + + if (prepend && account) { + const notifKind = { + favourite: 'favourited', + reblog: 'boosted', + reblogged_by: 'boosted', + }[prepend]; + + selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`; + } + + const handlers = { + 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 ( + <HotKeys handlers={handlers}> + <div + className={ + `status${ + muted ? ' muted' : '' + } status-${status.get('visibility')}${ + isExpanded === false ? ' collapsed' : '' + }${ + isExpanded === false && background ? ' has-background' : '' + }${ + this.state.markedForDelete ? ' marked-for-delete' : '' + }` + } + style={{ + backgroundImage: ( + isExpanded === false && background ? + `url(${background})` : + 'none' + ), + }} + {...selectorAttribs} + ref={handleRef} + > + {prepend && account ? ( + <StatusPrepend + type={prepend} + account={account} + parseClick={parseClick} + notificationId={this.props.notificationId} + /> + ) : null} + <StatusHeader + status={status} + friend={account} + mediaIcon={mediaIcon} + collapsible={settings.getIn(['collapsed', 'enabled'])} + collapsed={isExpanded === false} + parseClick={parseClick} + setExpansion={setExpansion} + /> + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + expanded={isExpanded} + setExpansion={setExpansion} + parseClick={parseClick} + disabled={!router} + /> + {isExpanded !== false ? ( + <StatusActionBar + {...other} + status={status} + account={status.get('account')} + /> + ) : null} + {notification ? ( + <NotificationOverlayContainer + notification={notification} + /> + ) : null} + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/status_action_bar.js b/app/javascript/themes/glitch/components/status_action_bar.js new file mode 100644 index 000000000..9d615ed7c --- /dev/null +++ b/app/javascript/themes/glitch/components/status_action_bar.js @@ -0,0 +1,188 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/action_bar + +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'themes/glitch/util/initial_state'; +import RelativeTimestamp from './relative_timestamp'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + more: { id: 'status.more', defaultMessage: 'More' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, +}); + +@injectIntl +export default class StatusActionBar extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onMention: PropTypes.func, + onMute: PropTypes.func, + onBlock: PropTypes.func, + onReport: PropTypes.func, + onEmbed: PropTypes.func, + onMuteConversation: PropTypes.func, + onPin: PropTypes.func, + withDismiss: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'withDismiss', + ] + + handleReplyClick = () => { + this.props.onReply(this.props.status, this.context.router.history); + } + + handleShareClick = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + + handleFavouriteClick = () => { + this.props.onFavourite(this.props.status); + } + + handleReblogClick = (e) => { + this.props.onReblog(this.props.status, e); + } + + handleDeleteClick = () => { + this.props.onDelete(this.props.status); + } + + handlePinClick = () => { + this.props.onPin(this.props.status); + } + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleMuteClick = () => { + this.props.onMute(this.props.status.get('account')); + } + + handleBlockClick = () => { + this.props.onBlock(this.props.status.get('account')); + } + + handleOpen = () => { + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + } + + render () { + const { status, intl, withDismiss } = this.props; + + const mutingConversation = status.get('muted'); + const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + + let menu = []; + let reblogIcon = 'retweet'; + let replyIcon; + let replyTitle; + + menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + + menu.push(null); + + if (status.getIn(['account', 'id']) === me || withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); + } + + if (status.getIn(['account', 'id']) === me) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + } + + if (status.get('in_reply_to_id', null) === null) { + replyIcon = 'reply'; + replyTitle = intl.formatMessage(messages.reply); + } else { + replyIcon = 'reply-all'; + replyTitle = intl.formatMessage(messages.replyAll); + } + + const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> + ); + + return ( + <div className='status__action-bar'> + <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> + <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> + <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + {shareButton} + + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> + </div> + + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/status_content.js b/app/javascript/themes/glitch/components/status_content.js new file mode 100644 index 000000000..3eba6eaa0 --- /dev/null +++ b/app/javascript/themes/glitch/components/status_content.js @@ -0,0 +1,245 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'themes/glitch/util/rtl'; +import { FormattedMessage } from 'react-intl'; +import Permalink from './permalink'; +import classnames from 'classnames'; + +export default class StatusContent extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + expanded: PropTypes.bool, + setExpansion: PropTypes.func, + media: PropTypes.element, + mediaIcon: PropTypes.string, + parseClick: PropTypes.func, + disabled: PropTypes.bool, + }; + + state = { + hidden: true, + }; + + _updateStatusLinks () { + const node = this.node; + const links = node.querySelectorAll('a'); + + for (var i = 0; i < links.length; ++i) { + let link = links[i]; + if (link.classList.contains('status-link')) { + continue; + } + link.classList.add('status-link'); + + let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); + + if (mention) { + link.addEventListener('click', this.onMentionClick.bind(this, mention), false); + link.setAttribute('title', mention.get('acct')); + } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else { + link.addEventListener('click', this.onLinkClick.bind(this), false); + link.setAttribute('title', link.href); + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + } + } + + componentDidMount () { + this._updateStatusLinks(); + } + + componentDidUpdate () { + this._updateStatusLinks(); + } + + onLinkClick = (e) => { + if (this.props.expanded === false) { + if (this.props.parseClick) this.props.parseClick(e); + } + } + + onMentionClick = (mention, e) => { + if (this.props.parseClick) { + this.props.parseClick(e, `/accounts/${mention.get('id')}`); + } + } + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (this.props.parseClick) { + this.props.parseClick(e, `/timelines/tag/${hashtag}`); + } + } + + handleMouseDown = (e) => { + this.startXY = [e.clientX, e.clientY]; + } + + handleMouseUp = (e) => { + const { parseClick } = this.props; + + if (!this.startXY) { + return; + } + + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0 && parseClick) { + parseClick(e); + } + + this.startXY = null; + } + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.setExpansion) { + this.props.setExpansion(this.props.expanded ? null : true); + } else { + this.setState({ hidden: !this.state.hidden }); + } + } + + setRef = (c) => { + this.node = c; + } + + render () { + const { + status, + media, + mediaIcon, + parseClick, + disabled, + } = this.props; + + const hidden = this.props.setExpansion ? !this.props.expanded : this.state.hidden; + + const content = { __html: status.get('contentHtml') }; + const spoilerContent = { __html: status.get('spoilerHtml') }; + const directionStyle = { direction: 'ltr' }; + const classNames = classnames('status__content', { + 'status__content--with-action': parseClick && !disabled, + 'status__content--with-spoiler': status.get('spoiler_text').length > 0, + }); + + if (isRtl(status.get('search_index'))) { + directionStyle.direction = 'rtl'; + } + + if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + <Permalink + to={`/accounts/${item.get('id')}`} + href={item.get('url')} + key={item.get('id')} + className='mention' + > + @<span>{item.get('username')}</span> + </Permalink> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []); + + const toggleText = hidden ? [ + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + />, + mediaIcon ? ( + <i + className={ + `fa fa-fw fa-${mediaIcon} status__content__spoiler-icon` + } + aria-hidden='true' + key='1' + /> + ) : null, + ] : [ + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + />, + ]; + + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + + return ( + <div className={classNames} tabIndex='0'> + <p + style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + > + <span dangerouslySetInnerHTML={spoilerContent} /> + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </p> + + {mentionsPlaceholder} + + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + <div + ref={this.setRef} + style={directionStyle} + tabIndex={!hidden ? 0 : null} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + {media} + </div> + + </div> + ); + } else if (parseClick) { + return ( + <div + className={classNames} + style={directionStyle} + tabIndex='0' + > + <div + ref={this.setRef} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + tabIndex='0' + /> + {media} + </div> + ); + } else { + return ( + <div + className='status__content' + style={directionStyle} + tabIndex='0' + > + <div ref={this.setRef} dangerouslySetInnerHTML={content} tabIndex='0' /> + {media} + </div> + ); + } + } + +} diff --git a/app/javascript/themes/glitch/components/status_header.js b/app/javascript/themes/glitch/components/status_header.js new file mode 100644 index 000000000..bfa996cd5 --- /dev/null +++ b/app/javascript/themes/glitch/components/status_header.js @@ -0,0 +1,120 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Mastodon imports. +import Avatar from './avatar'; +import AvatarOverlay from './avatar_overlay'; +import DisplayName from './display_name'; +import IconButton from './icon_button'; +import VisibilityIcon from './status_visibility_icon'; + +// Messages for use with internationalization stuff. +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + +@injectIntl +export default class StatusHeader extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map, + mediaIcon: PropTypes.string, + collapsible: PropTypes.bool, + collapsed: PropTypes.bool, + parseClick: PropTypes.func.isRequired, + setExpansion: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + // Handles clicks on collapsed button + handleCollapsedClick = (e) => { + const { collapsed, setExpansion } = this.props; + if (e.button === 0) { + setExpansion(collapsed ? null : false); + e.preventDefault(); + } + } + + // Handles clicks on account name/image + handleAccountClick = (e) => { + const { status, parseClick } = this.props; + parseClick(e, `/accounts/${+status.getIn(['account', 'id'])}`); + } + + // Rendering. + render () { + const { + status, + friend, + mediaIcon, + collapsible, + collapsed, + intl, + } = this.props; + + const account = status.get('account'); + + return ( + <header className='status__info'> + <a + href={account.get('url')} + target='_blank' + className='status__avatar' + onClick={this.handleAccountClick} + > + { + friend ? ( + <AvatarOverlay account={account} friend={friend} /> + ) : ( + <Avatar account={account} size={48} /> + ) + } + </a> + <a + href={account.get('url')} + target='_blank' + className='status__display-name' + onClick={this.handleAccountClick} + > + <DisplayName account={account} /> + </a> + <div className='status__info__icons'> + {mediaIcon ? ( + <i + className={`fa fa-fw fa-${mediaIcon}`} + aria-hidden='true' + /> + ) : null} + {( + <VisibilityIcon visibility={status.get('visibility')} /> + )} + {collapsible ? ( + <IconButton + className='status__collapse-button' + animate flip + active={collapsed} + title={ + collapsed ? + intl.formatMessage(messages.uncollapse) : + intl.formatMessage(messages.collapse) + } + icon='angle-double-up' + onClick={this.handleCollapsedClick} + /> + ) : null} + </div> + + </header> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/status_list.js b/app/javascript/themes/glitch/components/status_list.js new file mode 100644 index 000000000..ddb1354c6 --- /dev/null +++ b/app/javascript/themes/glitch/components/status_list.js @@ -0,0 +1,72 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import StatusContainer from 'themes/glitch/containers/status_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from './scrollable_list'; + +export default class StatusList extends ImmutablePureComponent { + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + onScrollToBottom: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + emptyMessage: PropTypes.node, + }; + + static defaultProps = { + 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} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )) + ) : null; + + return ( + <ScrollableList {...other} ref={this.setRef}> + {scrollableContent} + </ScrollableList> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/status_prepend.js b/app/javascript/themes/glitch/components/status_prepend.js new file mode 100644 index 000000000..bd2559e46 --- /dev/null +++ b/app/javascript/themes/glitch/components/status_prepend.js @@ -0,0 +1,83 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; + +export default class StatusPrepend extends React.PureComponent { + + static propTypes = { + type: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + parseClick: PropTypes.func.isRequired, + notificationId: PropTypes.number, + }; + + handleClick = (e) => { + const { account, parseClick } = this.props; + parseClick(e, `/accounts/${+account.get('id')}`); + } + + Message = () => { + const { type, account } = this.props; + let link = ( + <a + onClick={this.handleClick} + href={account.get('url')} + className='status__display-name' + > + <b + dangerouslySetInnerHTML={{ + __html : account.get('display_name_html') || account.get('username'), + }} + /> + </a> + ); + switch (type) { + case 'reblogged_by': + return ( + <FormattedMessage + id='status.reblogged_by' + defaultMessage='{name} boosted' + values={{ name : link }} + /> + ); + case 'favourite': + return ( + <FormattedMessage + id='notification.favourite' + defaultMessage='{name} favourited your status' + values={{ name : link }} + /> + ); + case 'reblog': + return ( + <FormattedMessage + id='notification.reblog' + defaultMessage='{name} boosted your status' + values={{ name : link }} + /> + ); + } + return null; + } + + render () { + const { Message } = this; + const { type } = this.props; + + return !type ? null : ( + <aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}> + <div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> + <i + className={`fa fa-fw fa-${ + type === 'favourite' ? 'star star-icon' : 'retweet' + } status__prepend-icon`} + /> + </div> + <Message /> + </aside> + ); + } + +} diff --git a/app/javascript/themes/glitch/components/status_visibility_icon.js b/app/javascript/themes/glitch/components/status_visibility_icon.js new file mode 100644 index 000000000..017b69cbb --- /dev/null +++ b/app/javascript/themes/glitch/components/status_visibility_icon.js @@ -0,0 +1,48 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + +@injectIntl +export default class VisibilityIcon extends ImmutablePureComponent { + + static propTypes = { + visibility: PropTypes.string, + intl: PropTypes.object.isRequired, + withLabel: PropTypes.bool, + }; + + render() { + const { withLabel, visibility, intl } = this.props; + + const visibilityClass = { + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }[visibility]; + + const label = intl.formatMessage(messages[visibility]); + + const icon = (<i + className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`} + title={label} + aria-hidden='true' + />); + + if (withLabel) { + return (<span style={{ whiteSpace: 'nowrap' }}>{icon} {label}</span>); + } else { + return icon; + } + } + +} diff --git a/app/javascript/themes/glitch/containers/account_container.js b/app/javascript/themes/glitch/containers/account_container.js new file mode 100644 index 000000000..c1ce49987 --- /dev/null +++ b/app/javascript/themes/glitch/containers/account_container.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { makeGetAccount } from 'themes/glitch/selectors'; +import Account from 'themes/glitch/components/account'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + muteAccount, + unmuteAccount, +} from 'themes/glitch/actions/accounts'; +import { openModal } from 'themes/glitch/actions/modal'; +import { initMuteModal } from 'themes/glitch/actions/mutes'; +import { unfollowModal } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + + + onMuteNotifications (account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/themes/glitch/containers/card_container.js b/app/javascript/themes/glitch/containers/card_container.js new file mode 100644 index 000000000..8285437bb --- /dev/null +++ b/app/javascript/themes/glitch/containers/card_container.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Card from 'themes/glitch/features/status/components/card'; +import { fromJS } from 'immutable'; + +export default class CardContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string, + card: PropTypes.array.isRequired, + }; + + render () { + const { card, ...props } = this.props; + return <Card card={fromJS(card)} {...props} />; + } + +} diff --git a/app/javascript/themes/glitch/containers/compose_container.js b/app/javascript/themes/glitch/containers/compose_container.js new file mode 100644 index 000000000..82980ee36 --- /dev/null +++ b/app/javascript/themes/glitch/containers/compose_container.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from 'themes/glitch/store/configureStore'; +import { hydrateStore } from 'themes/glitch/actions/store'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import Compose from 'themes/glitch/features/standalone/compose'; +import initialState from 'themes/glitch/util/initial_state'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const store = configureStore(); + +if (initialState) { + store.dispatch(hydrateStore(initialState)); +} + +export default class TimelineContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + render () { + const { locale } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Provider store={store}> + <Compose /> + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/themes/glitch/containers/dropdown_menu_container.js b/app/javascript/themes/glitch/containers/dropdown_menu_container.js new file mode 100644 index 000000000..15e8da2e3 --- /dev/null +++ b/app/javascript/themes/glitch/containers/dropdown_menu_container.js @@ -0,0 +1,16 @@ +import { openModal, closeModal } from 'themes/glitch/actions/modal'; +import { connect } from 'react-redux'; +import DropdownMenu from 'themes/glitch/components/dropdown_menu'; +import { isUserTouching } from 'themes/glitch/util/is_mobile'; + +const mapStateToProps = state => ({ + isModalOpen: state.get('modal').modalType === 'ACTIONS', +}); + +const mapDispatchToProps = dispatch => ({ + isUserTouching, + onModalOpen: props => dispatch(openModal('ACTIONS', props)), + onModalClose: () => dispatch(closeModal()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/themes/glitch/containers/intersection_observer_article_container.js b/app/javascript/themes/glitch/containers/intersection_observer_article_container.js new file mode 100644 index 000000000..6ede64738 --- /dev/null +++ b/app/javascript/themes/glitch/containers/intersection_observer_article_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import IntersectionObserverArticle from 'themes/glitch/components/intersection_observer_article'; +import { setHeight } from 'themes/glitch/actions/height_cache'; + +const makeMapStateToProps = (state, props) => ({ + cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), +}); + +const mapDispatchToProps = (dispatch) => ({ + + onHeightChange (key, id, height) { + dispatch(setHeight(key, id, height)); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); diff --git a/app/javascript/themes/glitch/containers/mastodon.js b/app/javascript/themes/glitch/containers/mastodon.js new file mode 100644 index 000000000..348470637 --- /dev/null +++ b/app/javascript/themes/glitch/containers/mastodon.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from 'themes/glitch/store/configureStore'; +import { showOnboardingOnce } from 'themes/glitch/actions/onboarding'; +import { BrowserRouter, Route } from 'react-router-dom'; +import { ScrollContext } from 'react-router-scroll-4'; +import UI from 'themes/glitch/features/ui'; +import { hydrateStore } from 'themes/glitch/actions/store'; +import { connectUserStream } from 'themes/glitch/actions/streaming'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import initialState from 'themes/glitch/util/initial_state'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export const store = configureStore(); +const hydrateAction = hydrateStore(initialState); +store.dispatch(hydrateAction); + +export default class Mastodon extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + componentDidMount() { + this.disconnect = store.dispatch(connectUserStream()); + + // Desktop notifications + // Ask after 1 minute + if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { + window.setTimeout(() => Notification.requestPermission(), 60 * 1000); + } + + // Protocol handler + // Ask after 5 minutes + if (typeof navigator.registerProtocolHandler !== 'undefined') { + const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; + window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); + } + + store.dispatch(showOnboardingOnce()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + render () { + const { locale } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Provider store={store}> + <BrowserRouter basename='/web'> + <ScrollContext> + <Route path='/' component={UI} /> + </ScrollContext> + </BrowserRouter> + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/themes/glitch/containers/media_gallery_container.js b/app/javascript/themes/glitch/containers/media_gallery_container.js new file mode 100644 index 000000000..86965f73b --- /dev/null +++ b/app/javascript/themes/glitch/containers/media_gallery_container.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import MediaGallery from 'themes/glitch/components/media_gallery'; +import { fromJS } from 'immutable'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export default class MediaGalleryContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + media: PropTypes.array.isRequired, + }; + + handleOpenMedia = () => {} + + render () { + const { locale, media, ...props } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <MediaGallery + {...props} + media={fromJS(media)} + onOpenMedia={this.handleOpenMedia} + /> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/themes/glitch/containers/notification_purge_buttons_container.js b/app/javascript/themes/glitch/containers/notification_purge_buttons_container.js new file mode 100644 index 000000000..ee4cb84cd --- /dev/null +++ b/app/javascript/themes/glitch/containers/notification_purge_buttons_container.js @@ -0,0 +1,49 @@ +// Package imports. +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Our imports. +import NotificationPurgeButtons from 'themes/glitch/components/notification_purge_buttons'; +import { + deleteMarkedNotifications, + enterNotificationClearingMode, + markAllNotifications, +} from 'themes/glitch/actions/notifications'; +import { openModal } from 'themes/glitch/actions/modal'; + +const messages = defineMessages({ + clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' }, + clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + + onDeleteMarked() { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(deleteMarkedNotifications()), + })); + }, + + onMarkAll() { + dispatch(markAllNotifications(true)); + }, + + onMarkNone() { + dispatch(markAllNotifications(false)); + }, + + onInvert() { + dispatch(markAllNotifications(null)); + }, +}); + +const mapStateToProps = state => ({ + markNewForDelete: state.getIn(['notifications', 'markNewForDelete']), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons)); diff --git a/app/javascript/themes/glitch/containers/status_container.js b/app/javascript/themes/glitch/containers/status_container.js new file mode 100644 index 000000000..14906723a --- /dev/null +++ b/app/javascript/themes/glitch/containers/status_container.js @@ -0,0 +1,150 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Status from 'themes/glitch/components/status'; +import { makeGetStatus } from 'themes/glitch/selectors'; +import { + replyCompose, + mentionCompose, +} from 'themes/glitch/actions/compose'; +import { + reblog, + favourite, + unreblog, + unfavourite, + pin, + unpin, +} from 'themes/glitch/actions/interactions'; +import { blockAccount } from 'themes/glitch/actions/accounts'; +import { muteStatus, unmuteStatus, deleteStatus } from 'themes/glitch/actions/statuses'; +import { initMuteModal } from 'themes/glitch/actions/mutes'; +import { initReport } from 'themes/glitch/actions/reports'; +import { openModal } from 'themes/glitch/actions/modal'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { boostModal, deleteModal } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => { + + let status = getStatus(state, props.id); + let reblogStatus = status ? status.get('reblog', null) : null; + let account = undefined; + let prepend = undefined; + + if (reblogStatus !== null && typeof reblogStatus === 'object') { + account = status.get('account'); + status = reblogStatus; + prepend = 'reblogged_by'; + } + + return { + status : status, + account : account || props.account, + settings : state.get('local_settings'), + prepend : prepend || props.prepend, + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch(replyCompose(status, router)); + }, + + onModalReblog (status) { + dispatch(reblog(status)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (e.shiftKey || !boostModal) { + this.onModalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); + } + } + }, + + onFavourite (status) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }, + + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal('EMBED', { url: status.get('url') })); + }, + + onDelete (status) { + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'))), + })); + } + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + + onOpenVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + + onBlock (account) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))), + })); + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onMute (account) { + dispatch(initMuteModal(account)); + }, + + onMuteConversation (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/themes/glitch/containers/timeline_container.js b/app/javascript/themes/glitch/containers/timeline_container.js new file mode 100644 index 000000000..a75f8808d --- /dev/null +++ b/app/javascript/themes/glitch/containers/timeline_container.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from 'themes/glitch/store/configureStore'; +import { hydrateStore } from 'themes/glitch/actions/store'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import PublicTimeline from 'themes/glitch/features/standalone/public_timeline'; +import HashtagTimeline from 'themes/glitch/features/standalone/hashtag_timeline'; +import initialState from 'themes/glitch/util/initial_state'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const store = configureStore(); + +if (initialState) { + store.dispatch(hydrateStore(initialState)); +} + +export default class TimelineContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + hashtag: PropTypes.string, + }; + + render () { + 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}> + {timeline} + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/themes/glitch/containers/video_container.js b/app/javascript/themes/glitch/containers/video_container.js new file mode 100644 index 000000000..2b0e98666 --- /dev/null +++ b/app/javascript/themes/glitch/containers/video_container.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import Video from 'themes/glitch/features/video'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export default class VideoContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + render () { + const { locale, ...props } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Video {...props} /> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/account/components/action_bar.js b/app/javascript/themes/glitch/features/account/components/action_bar.js new file mode 100644 index 000000000..0edd5c848 --- /dev/null +++ b/app/javascript/themes/glitch/features/account/components/action_bar.js @@ -0,0 +1,145 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container'; +import { Link } from 'react-router-dom'; +import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; +import { me } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, + media: { id: 'account.media', defaultMessage: 'Media' }, + blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, + showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, +}); + +@injectIntl +export default class ActionBar extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onFollow: PropTypes.func, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onBlockDomain: PropTypes.func.isRequired, + onUnblockDomain: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleShare = () => { + navigator.share({ + url: this.props.account.get('url'), + }); + } + + render () { + const { account, intl } = this.props; + + let menu = []; + let extraInfo = ''; + + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + if ('share' in navigator) { + menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); + } + menu.push(null); + menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` }); + menu.push(null); + + if (account.get('id') === me) { + menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); + } else { + const following = account.getIn(['relationship', 'following']); + if (following) { + if (following.get('reblogs')) { + menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } else { + menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } + } + + if (account.getIn(['relationship', 'muting'])) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); + } + + if (account.getIn(['relationship', 'blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); + } + + if (account.get('acct') !== account.get('username')) { + const domain = account.get('acct').split('@')[1]; + + extraInfo = ( + <div className='account__disclaimer'> + <FormattedMessage + id='account.disclaimer_full' + defaultMessage="Information below may reflect the user's profile incompletely." + /> + {' '} + <a target='_blank' rel='noopener' href={account.get('url')}> + <FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' /> + </a> + </div> + ); + + menu.push(null); + + if (account.getIn(['relationship', 'domain_blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain }); + } + } + + return ( + <div> + {extraInfo} + + <div className='account__action-bar'> + <div className='account__action-bar-dropdown'> + <DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' /> + </div> + + <div className='account__action-bar-links'> + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> + <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> + <strong><FormattedNumber value={account.get('statuses_count')} /></strong> + </Link> + + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> + <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> + <strong><FormattedNumber value={account.get('following_count')} /></strong> + </Link> + + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> + <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> + <strong><FormattedNumber value={account.get('followers_count')} /></strong> + </Link> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/account/components/header.js b/app/javascript/themes/glitch/features/account/components/header.js new file mode 100644 index 000000000..696bb1991 --- /dev/null +++ b/app/javascript/themes/glitch/features/account/components/header.js @@ -0,0 +1,99 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import Avatar from 'themes/glitch/components/avatar'; +import IconButton from 'themes/glitch/components/icon_button'; + +import emojify from 'themes/glitch/util/emoji'; +import { me } from 'themes/glitch/util/initial_state'; +import { processBio } from 'themes/glitch/util/bio_metadata'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, +}); + +@injectIntl +export default class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + onFollow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { account, intl } = this.props; + + if (!account) { + return null; + } + + let displayName = account.get('display_name_html'); + let info = ''; + let actionBtn = ''; + + if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { + info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>; + } + + if (me !== account.get('id')) { + if (account.getIn(['relationship', 'requested'])) { + actionBtn = ( + <div className='account--action-button'> + <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} /> + </div> + ); + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = ( + <div className='account--action-button'> + <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> + </div> + ); + } + } + + const { text, metadata } = processBio(account.get('note')); + + return ( + <div className='account__header__wrapper'> + <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> + <div> + <Avatar account={account} size={90} /> + + <span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} /> + <span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span> + <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} /> + + {info} + {actionBtn} + </div> + </div> + + {metadata.length && ( + <table className='account__metadata'> + <tbody> + {(() => { + let data = []; + for (let i = 0; i < metadata.length; i++) { + data.push( + <tr key={i}> + <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th> + <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td> + </tr> + ); + } + return data; + })()} + </tbody> + </table> + ) || null} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/account_gallery/components/media_item.js b/app/javascript/themes/glitch/features/account_gallery/components/media_item.js new file mode 100644 index 000000000..88c9156b5 --- /dev/null +++ b/app/javascript/themes/glitch/features/account_gallery/components/media_item.js @@ -0,0 +1,39 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Permalink from 'themes/glitch/components/permalink'; + +export default class MediaItem extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { media } = this.props; + const status = media.get('status'); + + let content, style; + + if (media.get('type') === 'gifv') { + content = <span className='media-gallery__gifv__label'>GIF</span>; + } + + if (!status.get('sensitive')) { + style = { backgroundImage: `url(${media.get('preview_url')})` }; + } + + return ( + <div className='account-gallery__item'> + <Permalink + to={`/statuses/${status.get('id')}`} + href={status.get('url')} + style={style} + > + {content} + </Permalink> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/account_gallery/index.js b/app/javascript/themes/glitch/features/account_gallery/index.js new file mode 100644 index 000000000..a21c089da --- /dev/null +++ b/app/javascript/themes/glitch/features/account_gallery/index.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { fetchAccount } from 'themes/glitch/actions/accounts'; +import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'themes/glitch/actions/timelines'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnBackButton from 'themes/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { getAccountGallery } from 'themes/glitch/selectors'; +import MediaItem from './components/media_item'; +import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container'; +import { FormattedMessage } from 'react-intl'; +import { ScrollContainer } from 'react-router-scroll-4'; +import LoadMore from 'themes/glitch/components/load_more'; + +const mapStateToProps = (state, props) => ({ + medias: getAccountGallery(state, props.params.accountId), + isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), + hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), +}); + +@connect(mapStateToProps) +export default class AccountGallery extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + medias: ImmutablePropTypes.list.isRequired, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + }; + + componentDidMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); + } + } + + handleScrollToBottom = () => { + if (this.props.hasMore) { + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); + } + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; + + if (150 > offset && !this.props.isLoading) { + this.handleScrollToBottom(); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.handleScrollToBottom(); + } + + render () { + const { medias, isLoading, hasMore } = this.props; + + let loadMore = null; + + if (!medias && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + if (!isLoading && medias.size > 0 && hasMore) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='account_gallery'> + <div className='scrollable' onScroll={this.handleScroll}> + <HeaderContainer accountId={this.props.params.accountId} /> + + <div className='account-section-headline'> + <FormattedMessage id='account.media' defaultMessage='Media' /> + </div> + + <div className='account-gallery__container'> + {medias.map(media => + <MediaItem + key={media.get('id')} + media={media} + /> + )} + {loadMore} + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/account_timeline/components/header.js b/app/javascript/themes/glitch/features/account_timeline/components/header.js new file mode 100644 index 000000000..c719a7bcb --- /dev/null +++ b/app/javascript/themes/glitch/features/account_timeline/components/header.js @@ -0,0 +1,95 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import InnerHeader from 'themes/glitch/features/account/components/header'; +import ActionBar from 'themes/glitch/features/account/components/action_bar'; +import MissingIndicator from 'themes/glitch/components/missing_indicator'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onBlockDomain: PropTypes.func.isRequired, + onUnblockDomain: PropTypes.func.isRequired, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMention = () => { + this.props.onMention(this.props.account, this.context.router.history); + } + + handleReport = () => { + this.props.onReport(this.props.account); + } + + handleReblogToggle = () => { + this.props.onReblogToggle(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + handleBlockDomain = () => { + const domain = this.props.account.get('acct').split('@')[1]; + + if (!domain) return; + + this.props.onBlockDomain(domain, this.props.account.get('id')); + } + + handleUnblockDomain = () => { + const domain = this.props.account.get('acct').split('@')[1]; + + if (!domain) return; + + this.props.onUnblockDomain(domain, this.props.account.get('id')); + } + + render () { + const { account } = this.props; + + if (account === null) { + return <MissingIndicator />; + } + + return ( + <div className='account-timeline__header'> + <InnerHeader + account={account} + onFollow={this.handleFollow} + /> + + <ActionBar + account={account} + onBlock={this.handleBlock} + onMention={this.handleMention} + onReblogToggle={this.handleReblogToggle} + onReport={this.handleReport} + onMute={this.handleMute} + onBlockDomain={this.handleBlockDomain} + onUnblockDomain={this.handleUnblockDomain} + /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js b/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js new file mode 100644 index 000000000..766b57b56 --- /dev/null +++ b/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'themes/glitch/selectors'; +import Header from '../components/header'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + unmuteAccount, +} from 'themes/glitch/actions/accounts'; +import { mentionCompose } from 'themes/glitch/actions/compose'; +import { initMuteModal } from 'themes/glitch/actions/mutes'; +import { initReport } from 'themes/glitch/actions/reports'; +import { openModal } from 'themes/glitch/actions/modal'; +import { blockDomain, unblockDomain } from 'themes/glitch/actions/domain_blocks'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { unfollowModal } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))), + })); + } + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onReblogToggle (account) { + if (account.getIn(['relationship', 'following', 'reblogs'])) { + dispatch(followAccount(account.get('id'), false)); + } else { + dispatch(followAccount(account.get('id'), true)); + } + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + + onBlockDomain (domain, accountId) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='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.' values={{ domain: <strong>{domain}</strong> }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain, accountId)), + })); + }, + + onUnblockDomain (domain, accountId) { + dispatch(unblockDomain(domain, accountId)); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/themes/glitch/features/account_timeline/index.js b/app/javascript/themes/glitch/features/account_timeline/index.js new file mode 100644 index 000000000..81336ef3a --- /dev/null +++ b/app/javascript/themes/glitch/features/account_timeline/index.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { fetchAccount } from 'themes/glitch/actions/accounts'; +import { refreshAccountTimeline, expandAccountTimeline } from 'themes/glitch/actions/timelines'; +import StatusList from '../../components/status_list'; +import LoadingIndicator from '../../components/loading_indicator'; +import Column from '../ui/components/column'; +import HeaderContainer from './containers/header_container'; +import ColumnBackButton from '../../components/column_back_button'; +import { List as ImmutableList } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), + isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), + hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), +}); + +@connect(mapStateToProps) +export default class AccountTimeline extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(refreshAccountTimeline(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId)); + } + } + + handleScrollToBottom = () => { + if (!this.props.isLoading && this.props.hasMore) { + this.props.dispatch(expandAccountTimeline(this.props.params.accountId)); + } + } + + render () { + const { statusIds, isLoading, hasMore } = this.props; + + if (!statusIds && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column name='account'> + <ColumnBackButton /> + + <StatusList + prepend={<HeaderContainer accountId={this.props.params.accountId} />} + scrollKey='account_timeline' + statusIds={statusIds} + isLoading={isLoading} + hasMore={hasMore} + onScrollToBottom={this.handleScrollToBottom} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/blocks/index.js b/app/javascript/themes/glitch/features/blocks/index.js new file mode 100644 index 000000000..70630818c --- /dev/null +++ b/app/javascript/themes/glitch/features/blocks/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import { fetchBlocks, expandBlocks } from 'themes/glitch/actions/blocks'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'blocks', 'items']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Blocks extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchBlocks()); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandBlocks()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column name='blocks' icon='ban' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='blocks'> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js b/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js new file mode 100644 index 000000000..988e36308 --- /dev/null +++ b/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingText from 'themes/glitch/components/setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..cd9c34365 --- /dev/null +++ b/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting } from 'themes/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'community']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['community', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/themes/glitch/features/community_timeline/index.js b/app/javascript/themes/glitch/features/community_timeline/index.js new file mode 100644 index 000000000..9d255bd01 --- /dev/null +++ b/app/javascript/themes/glitch/features/community_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { + refreshCommunityTimeline, + expandCommunityTimeline, +} from 'themes/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectCommunityStream } from 'themes/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local timeline' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class CommunityTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('COMMUNITY', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshCommunityTimeline()); + this.disconnect = dispatch(connectCommunityStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandCommunityTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='local'> + <ColumnHeader + icon='users' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`community_timeline-${columnId}`} + timelineId='community' + loadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options.js b/app/javascript/themes/glitch/features/compose/components/advanced_options.js new file mode 100644 index 000000000..045bad2e5 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/advanced_options.js @@ -0,0 +1,62 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports. +import ComposeAdvancedOptionsToggle from './advanced_options_toggle'; +import ComposeDropdown from './dropdown'; + +const messages = defineMessages({ + local_only_short : + { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' }, + local_only_long : + { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' }, + advanced_options_icon_title : + { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' }, +}); + +@injectIntl +export default class ComposeAdvancedOptions extends React.PureComponent { + + static propTypes = { + values : ImmutablePropTypes.contains({ + do_not_federate : PropTypes.bool.isRequired, + }).isRequired, + onChange : PropTypes.func.isRequired, + intl : PropTypes.object.isRequired, + }; + + render () { + const { intl, values } = this.props; + const options = [ + { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' }, + ]; + const anyEnabled = values.some((enabled) => enabled); + + const optionElems = options.map((option) => { + return ( + <ComposeAdvancedOptionsToggle + onChange={this.props.onChange} + active={values.get(option.name)} + key={option.name} + name={option.name} + shortText={intl.formatMessage(option.shortText)} + longText={intl.formatMessage(option.longText)} + /> + ); + }); + + return ( + <ComposeDropdown + title={intl.formatMessage(messages.advanced_options_icon_title)} + icon='home' + highlight={anyEnabled} + > + {optionElems} + </ComposeDropdown> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js new file mode 100644 index 000000000..98b3b6a44 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js @@ -0,0 +1,35 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import Toggle from 'react-toggle'; + +export default class ComposeAdvancedOptionsToggle extends React.PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + active: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + shortText: PropTypes.string.isRequired, + longText: PropTypes.string.isRequired, + } + + onToggle = () => { + this.props.onChange(this.props.name); + } + + render() { + const { active, shortText, longText } = this.props; + return ( + <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}> + <div className='advanced-options-dropdown__option__toggle'> + <Toggle checked={active} onChange={this.onToggle} /> + </div> + <div className='advanced-options-dropdown__option__content'> + <strong>{shortText}</strong> + {longText} + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/attach_options.js b/app/javascript/themes/glitch/features/compose/components/attach_options.js new file mode 100644 index 000000000..c396714f3 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/attach_options.js @@ -0,0 +1,131 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports // +import ComposeDropdown from './dropdown'; +import { uploadCompose } from 'themes/glitch/actions/compose'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { openModal } from 'themes/glitch/actions/modal'; + +const messages = defineMessages({ + upload : + { id: 'compose.attach.upload', defaultMessage: 'Upload a file' }, + doodle : + { id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, + attach : + { id: 'compose.attach', defaultMessage: 'Attach...' }, +}); + +const mapStateToProps = state => ({ + // This horrible expression is copied from vanilla upload_button_container + disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + resetFileKey: state.getIn(['compose', 'resetFileKey']), + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), +}); + +const mapDispatchToProps = dispatch => ({ + onSelectFile (files) { + dispatch(uploadCompose(files)); + }, + onOpenDoodle () { + dispatch(openModal('DOODLE', { noEsc: true })); + }, +}); + +@injectIntl +@connect(mapStateToProps, mapDispatchToProps) +export default class ComposeAttachOptions extends ImmutablePureComponent { + + static propTypes = { + intl : PropTypes.object.isRequired, + resetFileKey: PropTypes.number, + acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, + disabled: PropTypes.bool, + onSelectFile: PropTypes.func.isRequired, + onOpenDoodle: PropTypes.func.isRequired, + }; + + handleItemClick = bt => { + if (bt === 'upload') { + this.fileElement.click(); + } + + if (bt === 'doodle') { + this.props.onOpenDoodle(); + } + + this.dropdown.setState({ open: false }); + }; + + handleFileChange = (e) => { + if (e.target.files.length > 0) { + this.props.onSelectFile(e.target.files); + } + } + + setFileRef = (c) => { + this.fileElement = c; + } + + setDropdownRef = (c) => { + this.dropdown = c; + } + + render () { + const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; + + const options = [ + { icon: 'cloud-upload', text: messages.upload, name: 'upload' }, + { icon: 'paint-brush', text: messages.doodle, name: 'doodle' }, + ]; + + const optionElems = options.map((item) => { + const hdl = () => this.handleItemClick(item.name); + return ( + <div + role='button' + tabIndex='0' + key={item.name} + onClick={hdl} + className='privacy-dropdown__option' + > + <div className='privacy-dropdown__option__icon'> + <i className={`fa fa-fw fa-${item.icon}`} /> + </div> + + <div className='privacy-dropdown__option__content'> + <strong>{intl.formatMessage(item.text)}</strong> + </div> + </div> + ); + }); + + return ( + <div> + <ComposeDropdown + title={intl.formatMessage(messages.attach)} + icon='paperclip' + disabled={disabled} + ref={this.setDropdownRef} + > + {optionElems} + </ComposeDropdown> + <input + key={resetFileKey} + ref={this.setFileRef} + type='file' + multiple={false} + accept={acceptContentTypes.toArray().join(',')} + onChange={this.handleFileChange} + disabled={disabled} + style={{ display: 'none' }} + /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js new file mode 100644 index 000000000..4a98d89fe --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js @@ -0,0 +1,24 @@ +import React from 'react'; +import Avatar from 'themes/glitch/components/avatar'; +import DisplayName from 'themes/glitch/components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class AutosuggestAccount extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + + return ( + <div className='autosuggest-account'> + <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> + <DisplayName account={account} /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/character_counter.js b/app/javascript/themes/glitch/features/compose/components/character_counter.js new file mode 100644 index 000000000..0ecfc9141 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/character_counter.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { length } from 'stringz'; + +export default class CharacterCounter extends React.PureComponent { + + static propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired, + }; + + checkRemainingText (diff) { + if (diff < 0) { + return <span className='character-counter character-counter--over'>{diff}</span>; + } + + return <span className='character-counter'>{diff}</span>; + } + + render () { + const diff = this.props.max - length(this.props.text); + return this.checkRemainingText(diff); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/compose_form.js b/app/javascript/themes/glitch/features/compose/components/compose_form.js new file mode 100644 index 000000000..54b1944a4 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/compose_form.js @@ -0,0 +1,286 @@ +import React from 'react'; +import CharacterCounter from './character_counter'; +import Button from 'themes/glitch/components/button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import AutosuggestTextarea from 'themes/glitch/components/autosuggest_textarea'; +import { defineMessages, injectIntl } from 'react-intl'; +import Collapsable from 'themes/glitch/components/collapsable'; +import SpoilerButtonContainer from '../containers/spoiler_button_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; +import UploadFormContainer from '../containers/upload_form_container'; +import WarningContainer from '../containers/warning_container'; +import { isMobile } from 'themes/glitch/util/is_mobile'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { length } from 'stringz'; +import { countableText } from 'themes/glitch/util/counter'; +import ComposeAttachOptions from './attach_options'; +import initialState from 'themes/glitch/util/initial_state'; + +const maxChars = initialState.max_toot_chars; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, + publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, + publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, +}); + +@injectIntl +export default class ComposeForm extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + text: PropTypes.string.isRequired, + suggestion_token: PropTypes.string, + suggestions: ImmutablePropTypes.list, + spoiler: PropTypes.bool, + privacy: PropTypes.string, + advanced_options: ImmutablePropTypes.contains({ + do_not_federate: PropTypes.bool, + }), + spoiler_text: PropTypes.string, + focusDate: PropTypes.instanceOf(Date), + preselectDate: PropTypes.instanceOf(Date), + is_submitting: PropTypes.bool, + is_uploading: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onPrivacyChange: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + onChangeSpoilerText: PropTypes.func.isRequired, + onPaste: PropTypes.func.isRequired, + onPickEmoji: PropTypes.func.isRequired, + showSearch: PropTypes.bool, + settings : ImmutablePropTypes.map.isRequired, + }; + + static defaultProps = { + showSearch: false, + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + } + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + } + + handleSubmit2 = () => { + this.props.onPrivacyChange(this.props.settings.get('side_arm')); + this.handleSubmit(); + } + + handleSubmit = () => { + if (this.props.text !== this.autosuggestTextarea.textarea.value) { + // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) + // Update the state to match the current text + this.props.onChange(this.autosuggestTextarea.textarea.value); + } + + this.props.onSubmit(); + } + + onSuggestionsClearRequested = () => { + this.props.onClearSuggestions(); + } + + onSuggestionsFetchRequested = (token) => { + this.props.onFetchSuggestions(token); + } + + onSuggestionSelected = (tokenStart, token, value) => { + this._restoreCaret = null; + this.props.onSuggestionSelected(tokenStart, token, value); + } + + handleChangeSpoilerText = (e) => { + this.props.onChangeSpoilerText(e.target.value); + } + + componentWillReceiveProps (nextProps) { + // If this is the update where we've finished uploading, + // save the last caret position so we can restore it below! + if (!nextProps.is_uploading && this.props.is_uploading) { + this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart; + } + } + + componentDidUpdate (prevProps) { + // This statement does several things: + // - If we're beginning a reply, and, + // - Replying to zero or one users, places the cursor at the end of the textbox. + // - Replying to more than one user, selects any usernames past the first; + // this provides a convenient shortcut to drop everyone else from the conversation. + // - If we've just finished uploading an image, and have a saved caret position, + // restores the cursor to that position after the text changes! + if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) { + let selectionEnd, selectionStart; + + if (this.props.preselectDate !== prevProps.preselectDate) { + selectionEnd = this.props.text.length; + selectionStart = this.props.text.search(/\s/) + 1; + } else if (typeof this._restoreCaret === 'number') { + selectionStart = this._restoreCaret; + selectionEnd = this._restoreCaret; + } else { + selectionEnd = this.props.text.length; + selectionStart = selectionEnd; + } + + this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); + this.autosuggestTextarea.textarea.focus(); + } else if(prevProps.is_submitting && !this.props.is_submitting) { + this.autosuggestTextarea.textarea.focus(); + } + } + + setAutosuggestTextarea = (c) => { + this.autosuggestTextarea = c; + } + + handleEmojiPick = (data) => { + const position = this.autosuggestTextarea.textarea.selectionStart; + const emojiChar = data.native; + this._restoreCaret = position + emojiChar.length + 1; + this.props.onPickEmoji(position, data); + } + + render () { + const { intl, onPaste, showSearch } = this.props; + const disabled = this.props.is_submitting; + const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : ''; + const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join(''); + + const secondaryVisibility = this.props.settings.get('side_arm'); + let showSideArm = secondaryVisibility !== 'none'; + + let publishText = ''; + let publishText2 = ''; + let title = ''; + let title2 = ''; + + const privacyIcons = { + none: '', + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }; + + title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`; + + if (showSideArm) { + // Enhanced behavior with dual toot buttons + publishText = ( + <span> + { + <i + className={`fa fa-${privacyIcons[this.props.privacy]}`} + style={{ paddingRight: '5px' }} + /> + }{intl.formatMessage(messages.publish)} + </span> + ); + + title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`; + publishText2 = ( + <i + className={`fa fa-${privacyIcons[secondaryVisibility]}`} + aria-label={title2} + /> + ); + } else { + // Original vanilla behavior - no icon if public or unlisted + if (this.props.privacy === 'private' || this.props.privacy === 'direct') { + publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; + } else { + publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } + } + + const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0); + + return ( + <div className='compose-form'> + <Collapsable isVisible={this.props.spoiler} fullHeight={50}> + <div className='spoiler-input'> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span> + <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' /> + </label> + </div> + </Collapsable> + + <WarningContainer /> + + <ReplyIndicatorContainer /> + + <div className='compose-form__autosuggest-wrapper'> + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={disabled} + value={this.props.text} + onChange={this.handleChange} + suggestions={this.props.suggestions} + onKeyDown={this.handleKeyDown} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + autoFocus={!showSearch && !isMobile(window.innerWidth)} + /> + + <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> + </div> + + <div className='compose-form__modifiers'> + <UploadFormContainer /> + </div> + + <div className='compose-form__buttons'> + <ComposeAttachOptions /> + <SensitiveButtonContainer /> + <div className='compose-form__buttons-separator' /> + <PrivacyDropdownContainer /> + <SpoilerButtonContainer /> + <ComposeAdvancedOptionsContainer /> + </div> + + <div className='compose-form__publish'> + <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div> + <div className='compose-form__publish-button-wrapper'> + { + showSideArm ? + <Button + className='compose-form__publish__side-arm' + text={publishText2} + title={title2} + onClick={this.handleSubmit2} + disabled={submitDisabled} + /> : '' + } + <Button + className='compose-form__publish__primary' + text={publishText} + title={title} + onClick={this.handleSubmit} + disabled={submitDisabled} + /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/dropdown.js b/app/javascript/themes/glitch/features/compose/components/dropdown.js new file mode 100644 index 000000000..f3d9f094e --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/dropdown.js @@ -0,0 +1,77 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; + +// Our imports. +import IconButton from 'themes/glitch/components/icon_button'; + +const iconStyle = { + height : null, + lineHeight : '27px', +}; + +export default class ComposeDropdown extends React.PureComponent { + + static propTypes = { + title: PropTypes.string.isRequired, + icon: PropTypes.string, + highlight: PropTypes.bool, + disabled: PropTypes.bool, + children: PropTypes.arrayOf(PropTypes.node).isRequired, + }; + + state = { + open: false, + }; + + onGlobalClick = (e) => { + if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { + this.setState({ open: false }); + } + }; + + componentDidMount () { + window.addEventListener('click', this.onGlobalClick); + window.addEventListener('touchstart', this.onGlobalClick); + } + componentWillUnmount () { + window.removeEventListener('click', this.onGlobalClick); + window.removeEventListener('touchstart', this.onGlobalClick); + } + + onToggleDropdown = () => { + if (this.props.disabled) return; + this.setState({ open: !this.state.open }); + }; + + setRef = (c) => { + this.node = c; + }; + + render () { + const { open } = this.state; + let { highlight, title, icon, disabled } = this.props; + + if (!icon) icon = 'ellipsis-h'; + + return ( + <div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${highlight ? 'active' : ''} `}> + <div className='advanced-options-dropdown__value'> + <IconButton + className={'inverted'} + title={title} + icon={icon} active={open || highlight} + size={18} + style={iconStyle} + disabled={disabled} + onClick={this.onToggleDropdown} + /> + </div> + <div className='advanced-options-dropdown__dropdown'> + {this.props.children} + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js new file mode 100644 index 000000000..fd59efb85 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js @@ -0,0 +1,376 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { EmojiPicker as EmojiPickerAsync } from 'themes/glitch/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 'themes/glitch/util/emoji'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +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 = { + active: PropTypes.bool, + onSelect: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + }; + + handleClick = e => { + this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.active) { + this.attachListeners(); + } else { + this.removeListeners(); + } + } + + componentWillUnmount () { + this.removeListeners(); + } + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + attachListeners () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + removeListeners () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + render () { + const { active } = this.props; + + return ( + <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> + <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> + ); + } + +} + +class ModifierPicker extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + modifier: PropTypes.number, + onChange: PropTypes.func, + onClose: PropTypes.func, + onOpen: PropTypes.func, + }; + + handleClick = () => { + if (this.props.active) { + this.props.onClose(); + } else { + this.props.onOpen(); + } + } + + handleSelect = modifier => { + this.props.onChange(modifier); + this.props.onClose(); + } + + render () { + const { active, modifier } = this.props; + + return ( + <div className='emoji-picker-dropdown__modifiers'> + <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} /> + <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> + </div> + ); + } + +} + +@injectIntl +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, + placement: PropTypes.string, + arrowOffsetLeft: PropTypes.string, + arrowOffsetTop: PropTypes.string, + intl: PropTypes.object.isRequired, + skinTone: PropTypes.number.isRequired, + onSkinTone: PropTypes.func.isRequired, + }; + + static defaultProps = { + style: {}, + loading: true, + placement: 'bottom', + frequentlyUsedEmojis: [], + }; + + state = { + modifierOpen: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + 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; + } + + getI18n = () => { + const { intl } = this.props; + + return { + search: intl.formatMessage(messages.emoji_search), + notfound: intl.formatMessage(messages.emoji_not_found), + categories: { + search: intl.formatMessage(messages.search_results), + recent: intl.formatMessage(messages.recent), + people: intl.formatMessage(messages.people), + nature: intl.formatMessage(messages.nature), + foods: intl.formatMessage(messages.food), + activity: intl.formatMessage(messages.activity), + places: intl.formatMessage(messages.travel), + objects: intl.formatMessage(messages.objects), + symbols: intl.formatMessage(messages.symbols), + flags: intl.formatMessage(messages.flags), + custom: intl.formatMessage(messages.custom), + }, + }; + } + + handleClick = emoji => { + if (!emoji.native) { + emoji.native = emoji.colons; + } + + this.props.onClose(); + this.props.onPick(emoji); + } + + handleModifierOpen = () => { + this.setState({ modifierOpen: true }); + } + + handleModifierClose = () => { + this.setState({ modifierOpen: false }); + } + + handleModifierChange = modifier => { + this.props.onSkinTone(modifier); + } + + render () { + const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; + + if (loading) { + return <div style={{ width: 299 }} />; + } + + const title = intl.formatMessage(messages.emoji); + const { modifierOpen } = this.state; + + return ( + <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> + <EmojiPicker + perLine={8} + emojiSize={22} + sheetSize={32} + custom={buildCustomEmojis(custom_emojis)} + color='' + emoji='' + set='twitter' + title={title} + i18n={this.getI18n()} + onClick={this.handleClick} + include={categoriesSort} + recent={frequentlyUsedEmojis} + skin={skinTone} + showPreview={false} + backgroundImageFn={backgroundImageFn} + emojiTooltip + /> + + <ModifierPicker + active={modifierOpen} + modifier={skinTone} + onOpen={this.handleModifierOpen} + onClose={this.handleModifierClose} + onChange={this.handleModifierChange} + /> + </div> + ); + } + +} + +@injectIntl +export default class EmojiPickerDropdown extends React.PureComponent { + + static propTypes = { + custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + intl: PropTypes.object.isRequired, + onPickEmoji: PropTypes.func.isRequired, + onSkinTone: PropTypes.func.isRequired, + skinTone: PropTypes.number.isRequired, + }; + + state = { + active: false, + loading: false, + }; + + setRef = (c) => { + this.dropdown = c; + } + + 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 = () => { + this.setState({ active: false }); + } + + onToggle = (e) => { + if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (this.state.active) { + this.onHideDropdown(); + } else { + this.onShowDropdown(); + } + } + } + + handleKeyDown = e => { + if (e.key === 'Escape') { + this.onHideDropdown(); + } + } + + setTargetRef = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + render () { + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; + const title = intl.formatMessage(messages.emoji); + 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={classNames('emojione', { 'pulse-loading': active && loading })} + alt='🙂' + src={`${assetHost}/emoji/1f602.svg`} + /> + </div> + + <Overlay show={active} placement='bottom' target={this.findTarget}> + <EmojiPickerMenu + custom_emojis={this.props.custom_emojis} + loading={loading} + onClose={this.onHideDropdown} + onPick={onPickEmoji} + onSkinTone={onSkinTone} + skinTone={skinTone} + frequentlyUsedEmojis={frequentlyUsedEmojis} + /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/navigation_bar.js b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js new file mode 100644 index 000000000..24a70949b --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'themes/glitch/components/avatar'; +import IconButton from 'themes/glitch/components/icon_button'; +import Permalink from 'themes/glitch/components/permalink'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class NavigationBar extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + }; + + render () { + return ( + <div className='navigation-bar'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> + <Avatar account={this.props.account} size={40} /> + </Permalink> + + <div className='navigation-bar__profile'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong> + </Permalink> + + <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + </div> + + <IconButton title='' icon='close' onClick={this.props.onClose} /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js new file mode 100644 index 000000000..0cd92d174 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js @@ -0,0 +1,200 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from 'themes/glitch/components/icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'themes/glitch/util/optional_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' }, + public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, +}); + +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 { + + static propTypes = { + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + open: false, + }; + + handleToggle = () => { + if (this.props.isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + this.props.onModalOpen({ + actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), + onClick: this.handleModalActionClick, + }); + } + } else { + this.setState({ open: !this.state.open }); + } + } + + handleModalActionClick = (e) => { + e.preventDefault(); + + const { value } = this.options[e.currentTarget.getAttribute('data-index')]; + + this.props.onModalClose(); + this.props.onChange(value); + } + + handleKeyDown = e => { + switch(e.key) { + case 'Enter': + this.handleToggle(); + break; + case 'Escape': + this.handleClose(); + break; + } + } + + handleClose = () => { + this.setState({ open: false }); + } + + handleChange = value => { + this.props.onChange(value); + } + + componentWillMount () { + const { intl: { formatMessage } } = this.props; + + this.options = [ + { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, + { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, + { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, + { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, + ]; + } + + render () { + const { value, intl } = this.props; + const { open } = this.state; + + const valueOption = this.options.find(item => item.value === value); + + return ( + <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/themes/glitch/features/compose/components/reply_indicator.js b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js new file mode 100644 index 000000000..9a8d10ceb --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js @@ -0,0 +1,63 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from 'themes/glitch/components/avatar'; +import IconButton from 'themes/glitch/components/icon_button'; +import DisplayName from 'themes/glitch/components/display_name'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, +}); + +@injectIntl +export default class ReplyIndicator extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + onCancel: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onCancel(); + } + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: status.get('contentHtml') }; + + return ( + <div className='reply-indicator'> + <div className='reply-indicator__header'> + <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> + + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> + <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div> + <DisplayName account={status.get('account')} /> + </a> + </div> + + <div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/search.js b/app/javascript/themes/glitch/features/compose/components/search.js new file mode 100644 index 000000000..c3218137f --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/search.js @@ -0,0 +1,129 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'themes/glitch/util/optional_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 { + + static propTypes = { + value: PropTypes.string.isRequired, + submitted: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onShow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + expanded: false, + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + } + + handleClear = (e) => { + e.preventDefault(); + + if (this.props.value.length > 0 || this.props.submitted) { + this.props.onClear(); + } + } + + handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.props.onSubmit(); + } else if (e.key === 'Escape') { + document.querySelector('.ui').parentElement.focus(); + } + } + + noop () { + + } + + 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 ( + <div className='search'> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> + <input + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyDown} + onFocus={this.handleFocus} + onBlur={this.handleBlur} + /> + </label> + + <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> + <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/themes/glitch/features/compose/components/search_results.js b/app/javascript/themes/glitch/features/compose/components/search_results.js new file mode 100644 index 000000000..3fdafa5f3 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/search_results.js @@ -0,0 +1,65 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import StatusContainer from 'themes/glitch/containers/status_container'; +import { Link } from 'react-router-dom'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class SearchResults extends ImmutablePureComponent { + + static propTypes = { + results: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { results } = this.props; + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <div className='search-results__section'> + {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} + </div> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <div className='search-results__section'> + {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} + </div> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <div className='search-results__section'> + {results.get('hashtags').map(hashtag => + <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> + #{hashtag} + </Link> + )} + </div> + ); + } + + return ( + <div className='search-results'> + <div className='search-results__header'> + <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> + </div> + + {accounts} + {statuses} + {hashtags} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/text_icon_button.js b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js new file mode 100644 index 000000000..9c8ffab1f --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class TextIconButton extends React.PureComponent { + + static propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + ariaControls: PropTypes.string, + }; + + handleClick = (e) => { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { label, title, active, ariaControls } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> + {label} + </button> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/upload.js b/app/javascript/themes/glitch/features/compose/components/upload.js new file mode 100644 index 000000000..ded376ada --- /dev/null +++ b/app/javascript/themes/glitch/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 'themes/glitch/components/icon_button'; +import Motion from 'themes/glitch/util/optional_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: `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/themes/glitch/features/compose/components/upload_button.js b/app/javascript/themes/glitch/features/compose/components/upload_button.js new file mode 100644 index 000000000..d7742adfe --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/upload_button.js @@ -0,0 +1,77 @@ +import React from 'react'; +import IconButton from 'themes/glitch/components/icon_button'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const messages = defineMessages({ + upload: { id: 'upload_button.label', defaultMessage: 'Add media' }, +}); + +const makeMapStateToProps = () => { + const mapStateToProps = state => ({ + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), + }); + + return mapStateToProps; +}; + +const iconStyle = { + height: null, + lineHeight: '27px', +}; + +@connect(makeMapStateToProps) +@injectIntl +export default class UploadButton extends ImmutablePureComponent { + + static propTypes = { + disabled: PropTypes.bool, + onSelectFile: PropTypes.func.isRequired, + style: PropTypes.object, + resetFileKey: PropTypes.number, + acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, + intl: PropTypes.object.isRequired, + }; + + handleChange = (e) => { + if (e.target.files.length > 0) { + this.props.onSelectFile(e.target.files); + } + } + + handleClick = () => { + this.fileElement.click(); + } + + setRef = (c) => { + this.fileElement = c; + } + + render () { + + const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; + + return ( + <div className='compose-form__upload-button'> + <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span> + <input + key={resetFileKey} + ref={this.setRef} + type='file' + multiple={false} + accept={acceptContentTypes.toArray().join(',')} + onChange={this.handleChange} + disabled={disabled} + style={{ display: 'none' }} + /> + </label> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/upload_form.js b/app/javascript/themes/glitch/features/compose/components/upload_form.js new file mode 100644 index 000000000..b7f112205 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/upload_form.js @@ -0,0 +1,29 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import UploadProgressContainer from '../containers/upload_progress_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import UploadContainer from '../containers/upload_container'; + +export default class UploadForm extends ImmutablePureComponent { + + static propTypes = { + mediaIds: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { mediaIds } = this.props; + + return ( + <div className='compose-form__upload-wrapper'> + <UploadProgressContainer /> + + <div className='compose-form__uploads-wrapper'> + {mediaIds.map(id => ( + <UploadContainer id={id} key={id} /> + ))} + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/upload_progress.js b/app/javascript/themes/glitch/features/compose/components/upload_progress.js new file mode 100644 index 000000000..b923d0a22 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/upload_progress.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { FormattedMessage } from 'react-intl'; + +export default class UploadProgress extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + progress: PropTypes.number, + }; + + render () { + const { active, progress } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='upload-progress'> + <div className='upload-progress__icon'> + <i className='fa fa-upload' /> + </div> + + <div className='upload-progress__message'> + <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> + + <div className='upload-progress__backdrop'> + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> + {({ width }) => + <div className='upload-progress__tracker' style={{ width: `${width}%` }} /> + } + </Motion> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/components/warning.js b/app/javascript/themes/glitch/features/compose/components/warning.js new file mode 100644 index 000000000..82df55a31 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/components/warning.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +export default class Warning extends React.PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + }; + + render () { + const { message } = 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='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + {message} + </div> + )} + </Motion> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js new file mode 100644 index 000000000..9f168942a --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js @@ -0,0 +1,20 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import { toggleComposeAdvancedOption } from 'themes/glitch/actions/compose'; +import ComposeAdvancedOptions from '../components/advanced_options'; + +const mapStateToProps = state => ({ + values: state.getIn(['compose', 'advanced_options']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (option) { + dispatch(toggleComposeAdvancedOption(option)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions); diff --git a/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js new file mode 100644 index 000000000..96eb70c18 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestAccount from '../components/autosuggest_account'; +import { makeGetAccount } from 'themes/glitch/selectors'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestAccount); diff --git a/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js b/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js new file mode 100644 index 000000000..7afa988f1 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js @@ -0,0 +1,71 @@ +import { connect } from 'react-redux'; +import ComposeForm from '../components/compose_form'; +import { changeComposeVisibility, uploadCompose } from 'themes/glitch/actions/compose'; +import { + changeCompose, + submitCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, + changeComposeSpoilerText, + insertEmojiCompose, +} from 'themes/glitch/actions/compose'; + +const mapStateToProps = state => ({ + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + advanced_options: state.getIn(['compose', 'advanced_options']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), + privacy: state.getIn(['compose', 'privacy']), + focusDate: state.getIn(['compose', 'focusDate']), + preselectDate: state.getIn(['compose', 'preselectDate']), + is_submitting: state.getIn(['compose', 'is_submitting']), + is_uploading: state.getIn(['compose', 'is_uploading']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + settings: state.get('local_settings'), + filesAttached: state.getIn(['compose', 'media_attachments']).size > 0, +}); + +const mapDispatchToProps = (dispatch) => ({ + + onChange (text) { + dispatch(changeCompose(text)); + }, + + onPrivacyChange (value) { + dispatch(changeComposeVisibility(value)); + }, + + onSubmit () { + dispatch(submitCompose()); + }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, + + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, + + onPaste (files) { + dispatch(uploadCompose(files)); + }, + + onPickEmoji (position, data) { + dispatch(insertEmojiCompose(position, data)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js new file mode 100644 index 000000000..55a13bd65 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js @@ -0,0 +1,82 @@ +import { connect } from 'react-redux'; +import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; +import { changeSetting } from 'themes/glitch/actions/settings'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; +import { useEmoji } from 'themes/glitch/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.filter(e => e.get('visible_in_picker')).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: getCustomEmojis(state), + 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, mapDispatchToProps)(EmojiPickerDropdown); diff --git a/app/javascript/themes/glitch/features/compose/containers/navigation_container.js b/app/javascript/themes/glitch/features/compose/containers/navigation_container.js new file mode 100644 index 000000000..b6d737b46 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/navigation_container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import NavigationBar from '../components/navigation_bar'; +import { me } from 'themes/glitch/util/initial_state'; + +const mapStateToProps = state => { + return { + account: state.getIn(['accounts', me]), + }; +}; + +export default connect(mapStateToProps)(NavigationBar); diff --git a/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js new file mode 100644 index 000000000..9636ceab2 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import PrivacyDropdown from '../components/privacy_dropdown'; +import { changeComposeVisibility } from 'themes/glitch/actions/compose'; +import { openModal, closeModal } from 'themes/glitch/actions/modal'; +import { isUserTouching } from 'themes/glitch/util/is_mobile'; + +const mapStateToProps = state => ({ + isModalOpen: state.get('modal').modalType === 'ACTIONS', + value: state.getIn(['compose', 'privacy']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeVisibility(value)); + }, + + isUserTouching, + onModalOpen: props => dispatch(openModal('ACTIONS', props)), + onModalClose: () => dispatch(closeModal()), + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js new file mode 100644 index 000000000..6dcabb3cd --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from 'themes/glitch/actions/compose'; +import { makeGetStatus } from 'themes/glitch/selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = state => ({ + status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/javascript/themes/glitch/features/compose/containers/search_container.js b/app/javascript/themes/glitch/features/compose/containers/search_container.js new file mode 100644 index 000000000..a450d27e7 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/search_container.js @@ -0,0 +1,35 @@ +import { connect } from 'react-redux'; +import { + changeSearch, + clearSearch, + submitSearch, + showSearch, +} from 'themes/glitch/actions/search'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeSearch(value)); + }, + + onClear () { + dispatch(clearSearch()); + }, + + onSubmit () { + dispatch(submitSearch()); + }, + + onShow () { + dispatch(showSearch()); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/themes/glitch/features/compose/containers/search_results_container.js b/app/javascript/themes/glitch/features/compose/containers/search_results_container.js new file mode 100644 index 000000000..16d95d417 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/search_results_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']), +}); + +export default connect(mapStateToProps)(SearchResults); diff --git a/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js new file mode 100644 index 000000000..a710dd104 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import IconButton from 'themes/glitch/components/icon_button'; +import { changeComposeSensitivity } from 'themes/glitch/actions/compose'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }, +}); + +const mapStateToProps = state => ({ + visible: state.getIn(['compose', 'media_attachments']).size > 0, + active: state.getIn(['compose', 'sensitive']), + disabled: state.getIn(['compose', 'spoiler']), +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + }, + +}); + +class SensitiveButton extends React.PureComponent { + + static propTypes = { + visible: PropTypes.bool, + active: PropTypes.bool, + disabled: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { visible, active, disabled, onClick, intl } = this.props; + + return ( + <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> + {({ scale }) => { + const icon = active ? 'eye-slash' : 'eye'; + const className = classNames('compose-form__sensitive-button', { + 'compose-form__sensitive-button--visible': visible, + }); + return ( + <div className={className} style={{ transform: `scale(${scale})` }}> + <IconButton + className='compose-form__sensitive-button__icon' + title={intl.formatMessage(messages.title)} + icon={icon} + onClick={onClick} + size={18} + active={active} + disabled={disabled} + style={{ lineHeight: null, height: null }} + inverted + /> + </div> + ); + }} + </Motion> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js new file mode 100644 index 000000000..160e71ba9 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSpoilerness } from 'themes/glitch/actions/compose'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' }, +}); + +const mapStateToProps = (state, { intl }) => ({ + label: 'CW', + title: intl.formatMessage(messages.title), + active: state.getIn(['compose', 'spoiler']), + ariaControls: 'cw-spoiler-input', +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSpoilerness()); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_button_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_button_container.js new file mode 100644 index 000000000..f332eae1a --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/upload_button_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import UploadButton from '../components/upload_button'; +import { uploadCompose } from 'themes/glitch/actions/compose'; + +const mapStateToProps = state => ({ + disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + resetFileKey: state.getIn(['compose', 'resetFileKey']), +}); + +const mapDispatchToProps = dispatch => ({ + + onSelectFile (files) { + dispatch(uploadCompose(files)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(UploadButton); diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_container.js new file mode 100644 index 000000000..eea514bf5 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/upload_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import Upload from '../components/upload'; +import { undoUploadCompose, changeUploadCompose } from 'themes/glitch/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/themes/glitch/features/compose/containers/upload_form_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js new file mode 100644 index 000000000..a6798bf51 --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import UploadForm from '../components/upload_form'; + +const mapStateToProps = state => ({ + mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), +}); + +export default connect(mapStateToProps)(UploadForm); diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js new file mode 100644 index 000000000..0cfee96da --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import UploadProgress from '../components/upload_progress'; + +const mapStateToProps = state => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']), +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/javascript/themes/glitch/features/compose/containers/warning_container.js b/app/javascript/themes/glitch/features/compose/containers/warning_container.js new file mode 100644 index 000000000..225d6a1dd --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/containers/warning_container.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Warning from '../components/warning'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { me } from 'themes/glitch/util/initial_state'; + +const mapStateToProps = state => ({ + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), +}); + +const WarningWrapper = ({ needsLockWarning }) => { + if (needsLockWarning) { + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLockWarning: PropTypes.bool, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/themes/glitch/features/compose/index.js b/app/javascript/themes/glitch/features/compose/index.js new file mode 100644 index 000000000..3fcaf416f --- /dev/null +++ b/app/javascript/themes/glitch/features/compose/index.js @@ -0,0 +1,126 @@ +import React from 'react'; +import ComposeFormContainer from './containers/compose_form_container'; +import NavigationContainer from './containers/navigation_container'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { mountCompose, unmountCompose } from 'themes/glitch/actions/compose'; +import { openModal } from 'themes/glitch/actions/modal'; +import { changeLocalSetting } from 'themes/glitch/actions/local_settings'; +import { Link } from 'react-router-dom'; +import { injectIntl, defineMessages } from 'react-intl'; +import SearchContainer from './containers/search_container'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import SearchResultsContainer from './containers/search_results_container'; +import { changeComposing } from 'themes/glitch/actions/compose'; + +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, +}); + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Compose extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columns: ImmutablePropTypes.list.isRequired, + multiColumn: PropTypes.bool, + showSearch: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + this.props.dispatch(mountCompose()); + } + + componentWillUnmount () { + this.props.dispatch(unmountCompose()); + } + + onLayoutClick = (e) => { + const layout = e.currentTarget.getAttribute('data-mastodon-layout'); + this.props.dispatch(changeLocalSetting(['layout'], layout)); + e.preventDefault(); + } + + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + + onFocus = () => { + this.props.dispatch(changeComposing(true)); + } + + onBlur = () => { + this.props.dispatch(changeComposing(false)); + } + + render () { + const { multiColumn, showSearch, intl } = this.props; + + let header = ''; + + if (multiColumn) { + const { columns } = this.props; + header = ( + <nav className='drawer__header'> + <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link> + {!columns.some(column => column.get('id') === 'HOME') && ( + <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link> + )} + {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && ( + <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' /></Link> + )} + {!columns.some(column => column.get('id') === 'COMMUNITY') && ( + <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link> + )} + {!columns.some(column => column.get('id') === 'PUBLIC') && ( + <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link> + )} + <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a> + <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a> + </nav> + ); + } + + + + return ( + <div className='drawer'> + {header} + + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}> + <NavigationContainer onClose={this.onBlur} /> + <ComposeFormContainer /> + </div> + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + } + </Motion> + </div> + + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..2a40c65a5 --- /dev/null +++ b/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from 'themes/glitch/features/community_timeline/components/column_settings'; +import { changeSetting } from 'themes/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'direct']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['direct', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/themes/glitch/features/direct_timeline/index.js b/app/javascript/themes/glitch/features/direct_timeline/index.js new file mode 100644 index 000000000..6b29cf94d --- /dev/null +++ b/app/javascript/themes/glitch/features/direct_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { + refreshDirectTimeline, + expandDirectTimeline, +} from 'themes/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectDirectStream } from 'themes/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.direct', defaultMessage: 'Direct messages' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class DirectTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECT', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshDirectTimeline()); + this.disconnect = dispatch(connectDirectStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandDirectTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='envelope' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + loadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/favourited_statuses/index.js b/app/javascript/themes/glitch/features/favourited_statuses/index.js new file mode 100644 index 000000000..80345e0e2 --- /dev/null +++ b/app/javascript/themes/glitch/features/favourited_statuses/index.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'themes/glitch/actions/favourites'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns'; +import StatusList from 'themes/glitch/components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favourites' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'favourites', 'items']), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Favourites extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('FAVOURITES', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleScrollToBottom = () => { + this.props.dispatch(expandFavouritedStatuses()); + } + + render () { + const { intl, statusIds, columnId, multiColumn, hasMore } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='favourites'> + <ColumnHeader + icon='star' + title={intl.formatMessage(messages.heading)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + /> + + <StatusList + trackScroll={!pinned} + statusIds={statusIds} + scrollKey={`favourited_statuses-${columnId}`} + hasMore={hasMore} + onScrollToBottom={this.handleScrollToBottom} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/favourites/index.js b/app/javascript/themes/glitch/features/favourites/index.js new file mode 100644 index 000000000..d7b8ac3b1 --- /dev/null +++ b/app/javascript/themes/glitch/features/favourites/index.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import { fetchFavourites } from 'themes/glitch/actions/interactions'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnBackButton from 'themes/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), +}); + +@connect(mapStateToProps) +export default class Favourites extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + }; + + componentWillMount () { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchFavourites(nextProps.params.statusId)); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='favourites'> + <div className='scrollable'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js new file mode 100644 index 000000000..ce386d888 --- /dev/null +++ b/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from 'themes/glitch/components/permalink'; +import Avatar from 'themes/glitch/components/avatar'; +import DisplayName from 'themes/glitch/components/display_name'; +import IconButton from 'themes/glitch/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +@injectIntl +export default class AccountAuthorize extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { intl, account, onAuthorize, onReject } = this.props; + const content = { __html: account.get('note_emojified') }; + + return ( + <div className='account-authorize__wrapper'> + <div className='account-authorize'> + <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'> + <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__header__content' dangerouslySetInnerHTML={content} /> + </div> + + <div className='account--panel'> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js b/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js new file mode 100644 index 000000000..78ae77eee --- /dev/null +++ b/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from 'themes/glitch/selectors'; +import AccountAuthorize from '../components/account_authorize'; +import { authorizeFollowRequest, rejectFollowRequest } from 'themes/glitch/actions/accounts'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { id }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(id)); + }, + + onReject () { + dispatch(rejectFollowRequest(id)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/javascript/themes/glitch/features/follow_requests/index.js b/app/javascript/themes/glitch/features/follow_requests/index.js new file mode 100644 index 000000000..3f44f518a --- /dev/null +++ b/app/javascript/themes/glitch/features/follow_requests/index.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim'; +import AccountAuthorizeContainer from './containers/account_authorize_container'; +import { fetchFollowRequests, expandFollowRequests } from 'themes/glitch/actions/accounts'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class FollowRequests extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchFollowRequests()); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowRequests()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column name='follow-requests'> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <ScrollContainer scrollKey='follow_requests'> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountAuthorizeContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/followers/index.js b/app/javascript/themes/glitch/features/followers/index.js new file mode 100644 index 000000000..d586bf41d --- /dev/null +++ b/app/javascript/themes/glitch/features/followers/index.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowers, + expandFollowers, +} from 'themes/glitch/actions/accounts'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import Column from 'themes/glitch/features/ui/components/column'; +import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container'; +import LoadMore from 'themes/glitch/components/load_more'; +import ColumnBackButton from 'themes/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), + hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), +}); + +@connect(mapStateToProps) +export default class Followers extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowers(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchFollowers(nextProps.params.accountId)); + } + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { + this.props.dispatch(expandFollowers(this.props.params.accountId)); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.dispatch(expandFollowers(this.props.params.accountId)); + } + + render () { + const { accountIds, hasMore } = this.props; + + let loadMore = null; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + if (hasMore) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='followers'> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='followers'> + <HeaderContainer accountId={this.props.params.accountId} /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + {loadMore} + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/following/index.js b/app/javascript/themes/glitch/features/following/index.js new file mode 100644 index 000000000..c306faf21 --- /dev/null +++ b/app/javascript/themes/glitch/features/following/index.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowing, + expandFollowing, +} from 'themes/glitch/actions/accounts'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import Column from 'themes/glitch/features/ui/components/column'; +import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container'; +import LoadMore from 'themes/glitch/components/load_more'; +import ColumnBackButton from 'themes/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), + hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), +}); + +@connect(mapStateToProps) +export default class Following extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowing(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchFollowing(nextProps.params.accountId)); + } + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { + this.props.dispatch(expandFollowing(this.props.params.accountId)); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.dispatch(expandFollowing(this.props.params.accountId)); + } + + render () { + const { accountIds, hasMore } = this.props; + + let loadMore = null; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + if (hasMore) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='following'> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='following'> + <HeaderContainer accountId={this.props.params.accountId} /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + {loadMore} + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/generic_not_found/index.js b/app/javascript/themes/glitch/features/generic_not_found/index.js new file mode 100644 index 000000000..ccd2b87b2 --- /dev/null +++ b/app/javascript/themes/glitch/features/generic_not_found/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import Column from 'themes/glitch/features/ui/components/column'; +import MissingIndicator from 'themes/glitch/components/missing_indicator'; + +const GenericNotFound = () => ( + <Column> + <MissingIndicator /> + </Column> +); + +export default GenericNotFound; diff --git a/app/javascript/themes/glitch/features/getting_started/index.js b/app/javascript/themes/glitch/features/getting_started/index.js new file mode 100644 index 000000000..74b019cf1 --- /dev/null +++ b/app/javascript/themes/glitch/features/getting_started/index.js @@ -0,0 +1,145 @@ +import React from 'react'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnLink from 'themes/glitch/features/ui/components/column_link'; +import ColumnSubheading from 'themes/glitch/features/ui/components/column_subheading'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; +import { openModal } from 'themes/glitch/actions/modal'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, + settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, + community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, + mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, + info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, + show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' }, + pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, +}); + +const mapStateToProps = state => ({ + myAccount: state.getIn(['accounts', me]), + columns: state.getIn(['settings', 'columns']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class GettingStarted extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, + columns: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, + dispatch: PropTypes.func.isRequired, + }; + + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + + openOnboardingModal = (e) => { + e.preventDefault(); + this.props.dispatch(openModal('ONBOARDING')); + } + + render () { + const { intl, myAccount, columns, multiColumn } = this.props; + + let navItems = []; + + if (multiColumn) { + if (!columns.find(item => item.get('id') === 'HOME')) { + navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />); + } + + if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { + navItems.push(<ColumnLink key='1' icon='bell' text={intl.formatMessage(messages.notifications)} to='/notifications' />); + } + + if (!columns.find(item => item.get('id') === 'COMMUNITY')) { + navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />); + } + + if (!columns.find(item => item.get('id') === 'PUBLIC')) { + navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />); + } + } + + if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { + navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />); + } + + navItems = navItems.concat([ + <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, + <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, + ]); + + if (myAccount.get('locked')) { + navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); + } + + navItems = navItems.concat([ + <ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, + <ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, + ]); + + return ( + <Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> + <div className='scrollable optionally-scrollable'> + <div className='getting-started__wrapper'> + <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> + {navItems} + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> + <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> + <ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} /> + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> + </div> + + <div className='getting-started__footer'> + <div className='static-content getting-started'> + <p> + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'> + <FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /> + </a> • + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'> + <FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /> + </a> • + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'> + <FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /> + </a> + </p> + <p> + <FormattedMessage + id='getting_started.open_source_notice' + defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' + values={{ + github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, + Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a>, + }} + /> + </p> + </div> + </div> + </div> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/hashtag_timeline/index.js b/app/javascript/themes/glitch/features/hashtag_timeline/index.js new file mode 100644 index 000000000..a878931b3 --- /dev/null +++ b/app/javascript/themes/glitch/features/hashtag_timeline/index.js @@ -0,0 +1,118 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { + refreshHashtagTimeline, + expandHashtagTimeline, +} from 'themes/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns'; +import { FormattedMessage } from 'react-intl'; +import { connectHashtagStream } from 'themes/glitch/actions/streaming'; + +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, +}); + +@connect(mapStateToProps) +export default class HashtagTimeline extends React.PureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + columnId: PropTypes.string, + dispatch: PropTypes.func.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HASHTAG', { id: this.props.params.id })); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + _subscribe (dispatch, id) { + this.disconnect = dispatch(connectHashtagStream(id)); + } + + _unsubscribe () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + componentDidMount () { + const { dispatch } = this.props; + const { id } = this.props.params; + + dispatch(refreshHashtagTimeline(id)); + this._subscribe(dispatch, id); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.id !== this.props.params.id) { + this.props.dispatch(refreshHashtagTimeline(nextProps.params.id)); + this._unsubscribe(); + this._subscribe(this.props.dispatch, nextProps.params.id); + } + } + + componentWillUnmount () { + this._unsubscribe(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandHashtagTimeline(this.props.params.id)); + } + + render () { + const { hasUnread, columnId, multiColumn } = this.props; + const { id } = this.props.params; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='hashtag'> + <ColumnHeader + icon='hashtag' + active={hasUnread} + title={id} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + /> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`hashtag_timeline-${columnId}`} + timelineId={`hashtag:${id}`} + loadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js b/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js new file mode 100644 index 000000000..533da6c36 --- /dev/null +++ b/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingToggle from 'themes/glitch/features/notifications/components/setting_toggle'; +import SettingText from 'themes/glitch/components/setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='home_timeline' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> + </div> + + <div className='column-settings__row'> + <SettingToggle prefix='home_timeline' settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText prefix='home_timeline' settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..a0062f564 --- /dev/null +++ b/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from 'themes/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'home']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['home', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/themes/glitch/features/home_timeline/index.js b/app/javascript/themes/glitch/features/home_timeline/index.js new file mode 100644 index 000000000..8a65891cd --- /dev/null +++ b/app/javascript/themes/glitch/features/home_timeline/index.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { expandHomeTimeline } from 'themes/glitch/actions/timelines'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { Link } from 'react-router-dom'; + +const messages = defineMessages({ + title: { id: 'column.home', defaultMessage: 'Home' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class HomeTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HOME', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandHomeTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='home'> + <ColumnHeader + icon='home' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`home_timeline-${columnId}`} + loadMore={this.handleLoadMore} + timelineId='home' + emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/local_settings/index.js b/app/javascript/themes/glitch/features/local_settings/index.js new file mode 100644 index 000000000..6c5d51413 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/index.js @@ -0,0 +1,68 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +// Our imports +import LocalSettingsPage from './page'; +import LocalSettingsNavigation from './navigation'; +import { closeModal } from 'themes/glitch/actions/modal'; +import { changeLocalSetting } from 'themes/glitch/actions/local_settings'; + +// Stylesheet imports +import './style.scss'; + +const mapStateToProps = state => ({ + settings: state.get('local_settings'), +}); + +const mapDispatchToProps = dispatch => ({ + onChange (setting, value) { + dispatch(changeLocalSetting(setting, value)); + }, + onClose () { + dispatch(closeModal()); + }, +}); + +class LocalSettings extends React.PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + state = { + currentIndex: 0, + }; + + navigateTo = (index) => + this.setState({ currentIndex: +index }); + + render () { + + const { navigateTo } = this; + const { onChange, onClose, settings } = this.props; + const { currentIndex } = this.state; + + return ( + <div className='glitch modal-root__modal local-settings'> + <LocalSettingsNavigation + index={currentIndex} + onClose={onClose} + onNavigate={navigateTo} + /> + <LocalSettingsPage + index={currentIndex} + onChange={onChange} + settings={settings} + /> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings); diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/index.js b/app/javascript/themes/glitch/features/local_settings/navigation/index.js new file mode 100644 index 000000000..fa35e83c7 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/navigation/index.js @@ -0,0 +1,74 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports +import LocalSettingsNavigationItem from './item'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + general: { id: 'settings.general', defaultMessage: 'General' }, + collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' }, + media: { id: 'settings.media', defaultMessage: 'Media' }, + preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, + close: { id: 'settings.close', defaultMessage: 'Close' }, +}); + +@injectIntl +export default class LocalSettingsNavigation extends React.PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onClose : PropTypes.func.isRequired, + onNavigate : PropTypes.func.isRequired, + }; + + render () { + + const { index, intl, onClose, onNavigate } = this.props; + + return ( + <nav className='glitch local-settings__navigation'> + <LocalSettingsNavigationItem + active={index === 0} + index={0} + onNavigate={onNavigate} + title={intl.formatMessage(messages.general)} + /> + <LocalSettingsNavigationItem + active={index === 1} + index={1} + onNavigate={onNavigate} + title={intl.formatMessage(messages.collapsed)} + /> + <LocalSettingsNavigationItem + active={index === 2} + index={2} + onNavigate={onNavigate} + title={intl.formatMessage(messages.media)} + /> + <LocalSettingsNavigationItem + active={index === 3} + href='/settings/preferences' + index={3} + icon='cog' + title={intl.formatMessage(messages.preferences)} + /> + <LocalSettingsNavigationItem + active={index === 4} + className='close' + index={4} + onNavigate={onClose} + title={intl.formatMessage(messages.close)} + /> + </nav> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js b/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js new file mode 100644 index 000000000..a352d5fb2 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js @@ -0,0 +1,69 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPage extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + className: PropTypes.string, + href: PropTypes.string, + icon: PropTypes.string, + index: PropTypes.number.isRequired, + onNavigate: PropTypes.func, + title: PropTypes.string, + }; + + handleClick = (e) => { + const { index, onNavigate } = this.props; + if (onNavigate) { + onNavigate(index); + e.preventDefault(); + } + } + + render () { + const { handleClick } = this; + const { + active, + className, + href, + icon, + onNavigate, + title, + } = this.props; + + const finalClassName = classNames('glitch', 'local-settings__navigation__item', { + active, + }, className); + + const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : null; + + if (href) return ( + <a + href={href} + className={finalClassName} + > + {iconElem} {title} + </a> + ); + else if (onNavigate) return ( + <a + onClick={handleClick} + role='button' + tabIndex='0' + className={finalClassName} + > + {iconElem} {title} + </a> + ); + else return null; + } + +} diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss b/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss new file mode 100644 index 000000000..7f7371993 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss @@ -0,0 +1,27 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__navigation__item { + display: block; + padding: 15px 20px; + color: inherit; + background: $primary-text-color; + border-bottom: 1px $ui-primary-color solid; + cursor: pointer; + text-decoration: none; + outline: none; + transition: background .3s; + + &:hover { + background: $ui-secondary-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + } + + &.close, &.close:hover { + background: $error-value-color; + color: $primary-text-color; + } +} diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/style.scss b/app/javascript/themes/glitch/features/local_settings/navigation/style.scss new file mode 100644 index 000000000..0336f943b --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/navigation/style.scss @@ -0,0 +1,10 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__navigation { + background: $primary-text-color; + color: $ui-base-color; + width: 200px; + font-size: 15px; + line-height: 20px; + overflow-y: auto; +} diff --git a/app/javascript/themes/glitch/features/local_settings/page/index.js b/app/javascript/themes/glitch/features/local_settings/page/index.js new file mode 100644 index 000000000..498230f7b --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/page/index.js @@ -0,0 +1,212 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +// Our imports +import LocalSettingsPageItem from './item'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' }, + layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' }, + layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' }, + side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' }, +}); + +@injectIntl +export default class LocalSettingsPage extends React.PureComponent { + + static propTypes = { + index : PropTypes.number, + intl : PropTypes.object.isRequired, + onChange : PropTypes.func.isRequired, + settings : ImmutablePropTypes.map.isRequired, + }; + + pages = [ + ({ intl, onChange, settings }) => ( + <div className='glitch local-settings__page general'> + <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['layout']} + id='mastodon-settings--layout' + options={[ + { value: 'auto', message: intl.formatMessage(messages.layout_auto) }, + { value: 'multiple', message: intl.formatMessage(messages.layout_desktop) }, + { value: 'single', message: intl.formatMessage(messages.layout_mobile) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.layout' defaultMessage='Layout:' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['stretch']} + id='mastodon-settings--stretch' + onChange={onChange} + > + <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['navbar_under']} + id='mastodon-settings--navbar_under' + onChange={onChange} + > + <FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' /> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['side_arm']} + id='mastodon-settings--side_arm' + options={[ + { value: 'none', message: intl.formatMessage(messages.side_arm_none) }, + { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) }, + { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) }, + { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) }, + { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' /> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ onChange, settings }) => ( + <div className='glitch local-settings__page collapsed'> + <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'enabled']} + id='mastodon-settings--collapsed-enabled' + onChange={onChange} + > + <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' /> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'all']} + id='mastodon-settings--collapsed-auto-all' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'notifications']} + id='mastodon-settings--collapsed-auto-notifications' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'lengthy']} + id='mastodon-settings--collapsed-auto-lengthy' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'reblogs']} + id='mastodon-settings--collapsed-auto-reblogs' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'replies']} + id='mastodon-settings--collapsed-auto-replies' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'auto', 'media']} + id='mastodon-settings--collapsed-auto-media' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' /> + </LocalSettingsPageItem> + </section> + <section> + <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'backgrounds', 'user_backgrounds']} + id='mastodon-settings--collapsed-user-backgrouns' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'backgrounds', 'preview_images']} + id='mastodon-settings--collapsed-preview-images' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' /> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ onChange, settings }) => ( + <div className='glitch local-settings__page media'> + <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['media', 'letterbox']} + id='mastodon-settings--media-letterbox' + onChange={onChange} + > + <FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'fullwidth']} + id='mastodon-settings--media-fullwidth' + onChange={onChange} + > + <FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' /> + </LocalSettingsPageItem> + </div> + ), + ]; + + render () { + const { pages } = this; + const { index, intl, onChange, settings } = this.props; + const CurrentPage = pages[index] || pages[0]; + + return <CurrentPage intl={intl} onChange={onChange} settings={settings} />; + } + +} diff --git a/app/javascript/themes/glitch/features/local_settings/page/item/index.js b/app/javascript/themes/glitch/features/local_settings/page/item/index.js new file mode 100644 index 000000000..37e28c084 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/page/item/index.js @@ -0,0 +1,90 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Stylesheet imports +import './style.scss'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPageItem extends React.PureComponent { + + static propTypes = { + children: PropTypes.element.isRequired, + dependsOn: PropTypes.array, + dependsOnNot: PropTypes.array, + id: PropTypes.string.isRequired, + item: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + })), + settings: ImmutablePropTypes.map.isRequired, + }; + + handleChange = e => { + const { target } = e; + const { item, onChange, options } = this.props; + if (options && options.length > 0) onChange(item, target.value); + else onChange(item, target.checked); + } + + render () { + const { handleChange } = this; + const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props; + let enabled = true; + + if (dependsOn) { + for (let i = 0; i < dependsOn.length; i++) { + enabled = enabled && settings.getIn(dependsOn[i]); + } + } + if (dependsOnNot) { + for (let i = 0; i < dependsOnNot.length; i++) { + enabled = enabled && !settings.getIn(dependsOnNot[i]); + } + } + + if (options && options.length > 0) { + const currentValue = settings.getIn(item); + const optionElems = options && options.length > 0 && options.map((opt) => ( + <option + key={opt.value} + value={opt.value} + > + {opt.message} + </option> + )); + return ( + <label className='glitch local-settings__page__item' htmlFor={id}> + <p>{children}</p> + <p> + <select + id={id} + disabled={!enabled} + onBlur={handleChange} + onChange={handleChange} + value={currentValue} + > + {optionElems} + </select> + </p> + </label> + ); + } else return ( + <label className='glitch local-settings__page__item' htmlFor={id}> + <input + id={id} + type='checkbox' + checked={settings.getIn(item)} + onChange={handleChange} + disabled={!enabled} + /> + {children} + </label> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/local_settings/page/item/style.scss b/app/javascript/themes/glitch/features/local_settings/page/item/style.scss new file mode 100644 index 000000000..b2d8f7185 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/page/item/style.scss @@ -0,0 +1,7 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__page__item { + select { + margin-bottom: 5px; + } +} diff --git a/app/javascript/themes/glitch/features/local_settings/page/style.scss b/app/javascript/themes/glitch/features/local_settings/page/style.scss new file mode 100644 index 000000000..e9eedcad0 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/page/style.scss @@ -0,0 +1,9 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings__page { + display: block; + flex: auto; + padding: 15px 20px 15px 20px; + width: 360px; + overflow-y: auto; +} diff --git a/app/javascript/themes/glitch/features/local_settings/style.scss b/app/javascript/themes/glitch/features/local_settings/style.scss new file mode 100644 index 000000000..765294607 --- /dev/null +++ b/app/javascript/themes/glitch/features/local_settings/style.scss @@ -0,0 +1,34 @@ +@import 'styles/mastodon/variables'; + +.glitch.local-settings { + position: relative; + display: flex; + flex-direction: row; + background: $ui-secondary-color; + color: $ui-base-color; + border-radius: 8px; + height: 80vh; + width: 80vw; + max-width: 740px; + max-height: 450px; + overflow: hidden; + + label { + display: block; + } + + h1 { + font-size: 18px; + font-weight: 500; + line-height: 24px; + margin-bottom: 20px; + } + + h2 { + font-size: 15px; + font-weight: 500; + line-height: 20px; + margin-top: 20px; + margin-bottom: 10px; + } +} diff --git a/app/javascript/themes/glitch/features/mutes/index.js b/app/javascript/themes/glitch/features/mutes/index.js new file mode 100644 index 000000000..1158b8262 --- /dev/null +++ b/app/javascript/themes/glitch/features/mutes/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import { fetchMutes, expandMutes } from 'themes/glitch/actions/mutes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'mutes', 'items']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Mutes extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchMutes()); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandMutes()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='mutes'> + <div className='scrollable mutes' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js b/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js new file mode 100644 index 000000000..22a10753f --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default class ClearColumnButton extends React.Component { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><i className='fa fa-eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/notifications/components/column_settings.js b/app/javascript/themes/glitch/features/notifications/components/column_settings.js new file mode 100644 index 000000000..88a29d4d3 --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/components/column_settings.js @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import ClearColumnButton from './clear_column_button'; +import SettingToggle from './setting_toggle'; + +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + }; + + onPushChange = (key, checked) => { + this.props.onChange(['push', ...key], checked); + } + + render () { + const { settings, pushSettings, onChange, onClear } = this.props; + + const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; + const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; + const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; + + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; + const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />; + + return ( + <div> + <div className='column-settings__row'> + <ClearColumnButton onClick={onClear} /> + </div> + + <div role='group' aria-labelledby='notifications-follow'> + <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-favourite'> + <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-mention'> + <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-reblog'> + <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/notifications/components/follow.js b/app/javascript/themes/glitch/features/notifications/components/follow.js new file mode 100644 index 000000000..8a0f01736 --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/components/follow.js @@ -0,0 +1,97 @@ +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; + +// Our imports. +import Permalink from 'themes/glitch/components/permalink'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + 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, + }; + } + + render () { + const { account, notification } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/accounts/${account.get('id')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /> + ); + + // Renders. + return ( + <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> + + <AccountContainer id={account.get('id')} withNote={false} /> + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/notifications/components/notification.js b/app/javascript/themes/glitch/features/notifications/components/notification.js new file mode 100644 index 000000000..a309d3a42 --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/components/notification.js @@ -0,0 +1,88 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Our imports, +import StatusContainer from 'themes/glitch/containers/status_container'; +import NotificationFollow from './follow'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + settings: ImmutablePropTypes.map.isRequired, + }; + + renderFollow () { + const { notification } = this.props; + return ( + <NotificationFollow + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + /> + ); + } + + renderMention () { + const { notification } = this.props; + return ( + <StatusContainer + id={notification.get('status')} + notification={notification} + withDismiss + /> + ); + } + + renderFavourite () { + const { notification } = this.props; + return ( + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + prepend='favourite' + muted + notification={notification} + withDismiss + /> + ); + } + + renderReblog () { + const { notification } = this.props; + return ( + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + prepend='reblog' + muted + notification={notification} + withDismiss + /> + ); + } + + render () { + const { notification } = this.props; + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(); + case 'mention': + return this.renderMention(); + case 'favourite': + return this.renderFavourite(); + case 'reblog': + return this.renderReblog(); + default: + return null; + } + } + +} diff --git a/app/javascript/themes/glitch/features/notifications/components/overlay.js b/app/javascript/themes/glitch/features/notifications/components/overlay.js new file mode 100644 index 000000000..e56f9c628 --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/components/overlay.js @@ -0,0 +1,57 @@ +/** + * Notification overlay + */ + + +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, +}); + +@injectIntl +export default class NotificationOverlay extends ImmutablePureComponent { + + static propTypes = { + notification : ImmutablePropTypes.map.isRequired, + onMarkForDelete : PropTypes.func.isRequired, + show : PropTypes.bool.isRequired, + intl : PropTypes.object.isRequired, + }; + + onToggleMark = () => { + const mark = !this.props.notification.get('markedForDelete'); + const id = this.props.notification.get('id'); + this.props.onMarkForDelete(id, mark); + } + + render () { + const { notification, show, intl } = this.props; + + const active = notification.get('markedForDelete'); + const label = intl.formatMessage(messages.markForDeletion); + + return show ? ( + <div + aria-label={label} + role='checkbox' + aria-checked={active} + tabIndex={0} + className={`notification__dismiss-overlay ${active ? 'active' : ''}`} + onClick={this.onToggleMark} + > + <div className='wrappy'> + <div className='ckbox' aria-hidden='true' title={label}> + {active ? (<i className='fa fa-check' />) : ''} + </div> + </div> + </div> + ) : null; + } + +} diff --git a/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js b/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js new file mode 100644 index 000000000..281359d2a --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +export default class SettingToggle extends React.PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingKey: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + meta: PropTypes.node, + onChange: PropTypes.func.isRequired, + } + + onChange = ({ target }) => { + this.props.onChange(this.props.settingKey, target.checked); + } + + render () { + const { prefix, settings, settingKey, label, meta } = this.props; + const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); + + return ( + <div className='setting-toggle'> + <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <label htmlFor={id} className='setting-toggle__label'>{label}</label> + {meta && <span className='setting-meta__label'>{meta}</span>} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js new file mode 100644 index 000000000..ddc8495f4 --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js @@ -0,0 +1,44 @@ +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from 'themes/glitch/actions/settings'; +import { clearNotifications } from 'themes/glitch/actions/notifications'; +import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from 'themes/glitch/actions/push_notifications'; +import { openModal } from 'themes/glitch/actions/modal'; + +const messages = defineMessages({ + clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, + clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, +}); + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange (key, checked) { + if (key[0] === 'push') { + dispatch(changePushNotifications(key.slice(1), checked)); + } else { + dispatch(changeSetting(['notifications', ...key], checked)); + } + }, + + onSave () { + dispatch(saveSettings()); + dispatch(savePushNotificationSettings()); + }, + + onClear () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()), + })); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/themes/glitch/features/notifications/containers/notification_container.js b/app/javascript/themes/glitch/features/notifications/containers/notification_container.js new file mode 100644 index 000000000..b61aaa21c --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/containers/notification_container.js @@ -0,0 +1,27 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import { makeGetNotification } from 'themes/glitch/selectors'; +import Notification from '../components/notification'; +import { mentionCompose } from 'themes/glitch/actions/compose'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId), + settings: state.get('local_settings'), + notifCleaning: state.getIn(['notifications', 'cleaningMode']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + onMention: (account, router) => { + dispatch(mentionCompose(account, router)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js b/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js new file mode 100644 index 000000000..52649cdd7 --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js @@ -0,0 +1,18 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import NotificationOverlay from '../components/overlay'; +import { markNotificationForDelete } from 'themes/glitch/actions/notifications'; + +const mapDispatchToProps = dispatch => ({ + onMarkForDelete(id, yes) { + dispatch(markNotificationForDelete(id, yes)); + }, +}); + +const mapStateToProps = state => ({ + show: state.getIn(['notifications', 'cleaningMode']), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay); diff --git a/app/javascript/themes/glitch/features/notifications/index.js b/app/javascript/themes/glitch/features/notifications/index.js new file mode 100644 index 000000000..1ecde660a --- /dev/null +++ b/app/javascript/themes/glitch/features/notifications/index.js @@ -0,0 +1,193 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { + enterNotificationClearingMode, + expandNotifications, + scrollTopNotifications, +} from 'themes/glitch/actions/notifications'; +import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns'; +import NotificationContainer from './containers/notification_container'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { createSelector } from 'reselect'; +import { List as ImmutableList } from 'immutable'; +import { debounce } from 'lodash'; +import ScrollableList from 'themes/glitch/components/scrollable_list'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, +}); + +const getNotifications = createSelector([ + state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + state => state.getIn(['notifications', 'items']), +], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); + +const mapStateToProps = state => ({ + notifications: getNotifications(state), + localSettings: state.get('local_settings'), + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0, + hasMore: !!state.getIn(['notifications', 'next']), + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), +}); + +/* glitch */ +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + dispatch, +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class Notifications extends React.PureComponent { + + static propTypes = { + columnId: PropTypes.string, + notifications: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + intl: PropTypes.object.isRequired, + isLoading: PropTypes.bool, + isUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, + }; + + static defaultProps = { + trackScroll: true, + }; + + handleScrollToBottom = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + this.props.dispatch(expandNotifications()); + }, 300, { leading: true }); + + handleScrollToTop = debounce(() => { + this.props.dispatch(scrollTopNotifications(true)); + }, 100); + + handleScroll = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + }, 100); + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('NOTIFICATIONS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setColumnRef = c => { + 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; + const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; + + let scrollableContent = null; + + 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')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); + } else { + scrollableContent = null; + } + + this.scrollableContent = scrollableContent; + + const scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + hasMore={hasMore} + emptyMessage={emptyMessage} + onScrollToBottom={this.handleScrollToBottom} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + shouldUpdateScroll={shouldUpdateScroll} + > + {scrollableContent} + </ScrollableList> + ); + + return ( + <Column + ref={this.setColumnRef} + name='notifications' + extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null} + > + <ColumnHeader + icon='bell' + active={isUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + localSettings={this.props.localSettings} + notifCleaning + notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text + onEnterCleaningMode={this.props.onEnterCleaningMode} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + {scrollContainer} + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/pinned_statuses/index.js b/app/javascript/themes/glitch/features/pinned_statuses/index.js new file mode 100644 index 000000000..0a3997850 --- /dev/null +++ b/app/javascript/themes/glitch/features/pinned_statuses/index.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchPinnedStatuses } from 'themes/glitch/actions/pin_statuses'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim'; +import StatusList from 'themes/glitch/components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.pins', defaultMessage: 'Pinned toot' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'pins', 'items']), + hasMore: !!state.getIn(['status_lists', 'pins', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class PinnedStatuses extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + hasMore: PropTypes.bool.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchPinnedStatuses()); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + render () { + const { intl, statusIds, hasMore } = this.props; + + return ( + <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> + <ColumnBackButtonSlim /> + <StatusList + statusIds={statusIds} + scrollKey='pinned_statuses' + hasMore={hasMore} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..0185a7724 --- /dev/null +++ b/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from 'themes/glitch/features/community_timeline/components/column_settings'; +import { changeSetting } from 'themes/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'public']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['public', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/themes/glitch/features/public_timeline/index.js b/app/javascript/themes/glitch/features/public_timeline/index.js new file mode 100644 index 000000000..f5b3865af --- /dev/null +++ b/app/javascript/themes/glitch/features/public_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { + refreshPublicTimeline, + expandPublicTimeline, +} from 'themes/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectPublicStream } from 'themes/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.public', defaultMessage: 'Federated timeline' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class PublicTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasUnread: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('PUBLIC', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshPublicTimeline()); + this.disconnect = dispatch(connectPublicStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandPublicTimeline()); + } + + render () { + const { intl, columnId, hasUnread, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='federated'> + <ColumnHeader + icon='globe' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + <StatusListContainer + timelineId='public' + loadMore={this.handleLoadMore} + trackScroll={!pinned} + scrollKey={`public_timeline-${columnId}`} + emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/reblogs/index.js b/app/javascript/themes/glitch/features/reblogs/index.js new file mode 100644 index 000000000..8723f7c7c --- /dev/null +++ b/app/javascript/themes/glitch/features/reblogs/index.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; +import { fetchReblogs } from 'themes/glitch/actions/interactions'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'themes/glitch/containers/account_container'; +import Column from 'themes/glitch/features/ui/components/column'; +import ColumnBackButton from 'themes/glitch/components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), +}); + +@connect(mapStateToProps) +export default class Reblogs extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + }; + + componentWillMount () { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchReblogs(nextProps.params.statusId)); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='reblogs'> + <div className='scrollable reblogs'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/report/components/status_check_box.js b/app/javascript/themes/glitch/features/report/components/status_check_box.js new file mode 100644 index 000000000..cc9232201 --- /dev/null +++ b/app/javascript/themes/glitch/features/report/components/status_check_box.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +export default class StatusCheckBox extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + checked: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + disabled: PropTypes.bool, + }; + + render () { + const { status, checked, onToggle, disabled } = this.props; + const content = { __html: status.get('contentHtml') }; + + if (status.get('reblog')) { + return null; + } + + return ( + <div className='status-check-box'> + <div + className='status__content' + dangerouslySetInnerHTML={content} + /> + + <div className='status-check-box-toggle'> + <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js b/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js new file mode 100644 index 000000000..40d55fb3c --- /dev/null +++ b/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import StatusCheckBox from '../components/status_check_box'; +import { toggleStatusReport } from 'themes/glitch/actions/reports'; +import { Set as ImmutableSet } from 'immutable'; + +const mapStateToProps = (state, { id }) => ({ + status: state.getIn(['statuses', id]), + checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id), +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onToggle (e) { + dispatch(toggleStatusReport(id, e.target.checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); diff --git a/app/javascript/themes/glitch/features/standalone/compose/index.js b/app/javascript/themes/glitch/features/standalone/compose/index.js new file mode 100644 index 000000000..8a8118178 --- /dev/null +++ b/app/javascript/themes/glitch/features/standalone/compose/index.js @@ -0,0 +1,20 @@ +import React from 'react'; +import ComposeFormContainer from 'themes/glitch/features/compose/containers/compose_form_container'; +import NotificationsContainer from 'themes/glitch/features/ui/containers/notifications_container'; +import LoadingBarContainer from 'themes/glitch/features/ui/containers/loading_bar_container'; +import ModalContainer from 'themes/glitch/features/ui/containers/modal_container'; + +export default class Compose extends React.PureComponent { + + render () { + return ( + <div> + <ComposeFormContainer /> + <NotificationsContainer /> + <ModalContainer /> + <LoadingBarContainer className='loading-bar' /> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js new file mode 100644 index 000000000..7c56f264f --- /dev/null +++ b/app/javascript/themes/glitch/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 'themes/glitch/features/ui/containers/status_list_container'; +import { + refreshHashtagTimeline, + expandHashtagTimeline, +} from 'themes/glitch/actions/timelines'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/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/themes/glitch/features/standalone/public_timeline/index.js b/app/javascript/themes/glitch/features/standalone/public_timeline/index.js new file mode 100644 index 000000000..b3fb55288 --- /dev/null +++ b/app/javascript/themes/glitch/features/standalone/public_timeline/index.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container'; +import { + refreshPublicTimeline, + expandPublicTimeline, +} from 'themes/glitch/actions/timelines'; +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, +}); + +@connect() +@injectIntl +export default class PublicTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshPublicTimeline()); + + this.polling = setInterval(() => { + dispatch(refreshPublicTimeline()); + }, 3000); + } + + componentWillUnmount () { + if (typeof this.polling !== 'undefined') { + clearInterval(this.polling); + this.polling = null; + } + } + + handleLoadMore = () => { + this.props.dispatch(expandPublicTimeline()); + } + + render () { + const { intl } = this.props; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='globe' + title={intl.formatMessage(messages.title)} + onClick={this.handleHeaderClick} + /> + + <StatusListContainer + timelineId='public' + loadMore={this.handleLoadMore} + scrollKey='standalone_public_timeline' + trackScroll={false} + /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/status/components/action_bar.js b/app/javascript/themes/glitch/features/status/components/action_bar.js new file mode 100644 index 000000000..6cda988d1 --- /dev/null +++ b/app/javascript/themes/glitch/features/status/components/action_bar.js @@ -0,0 +1,129 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from 'themes/glitch/components/icon_button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container'; +import { defineMessages, injectIntl } from 'react-intl'; +import { me } from 'themes/glitch/util/initial_state'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, +}); + +@injectIntl +export default class ActionBar extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func.isRequired, + onReblog: PropTypes.func.isRequired, + onFavourite: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func, + onPin: PropTypes.func, + onEmbed: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + handleReplyClick = () => { + this.props.onReply(this.props.status); + } + + handleReblogClick = (e) => { + this.props.onReblog(this.props.status, e); + } + + handleFavouriteClick = () => { + this.props.onFavourite(this.props.status); + } + + handleDeleteClick = () => { + this.props.onDelete(this.props.status); + } + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handlePinClick = () => { + this.props.onPin(this.props.status); + } + + handleShare = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + + render () { + const { status, intl } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + + let menu = []; + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + + if (me === status.getIn(['account', 'id'])) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + } + + const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div> + ); + + let reblogIcon = 'retweet'; + //if (status.get('visibility') === 'direct') reblogIcon = 'envelope'; + // else if (status.get('visibility') === 'private') reblogIcon = 'lock'; + + let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); + + return ( + <div className='detailed-status__action-bar'> + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div> + <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div> + <div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> + {shareButton} + + <div className='detailed-status__action-bar-dropdown'> + <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/status/components/card.js b/app/javascript/themes/glitch/features/status/components/card.js new file mode 100644 index 000000000..bb83374b9 --- /dev/null +++ b/app/javascript/themes/glitch/features/status/components/card.js @@ -0,0 +1,125 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import punycode from 'punycode'; +import classnames from 'classnames'; + +const IDNA_PREFIX = 'xn--'; + +const decodeIDNA = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +export default class Card extends React.PureComponent { + + static propTypes = { + card: ImmutablePropTypes.map, + maxDescription: PropTypes.number, + }; + + static defaultProps = { + maxDescription: 50, + }; + + state = { + width: 0, + }; + + renderLink () { + const { card, maxDescription } = this.props; + + let image = ''; + let provider = card.get('provider_name'); + + if (card.get('image')) { + image = ( + <div className='status-card__image'> + <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} /> + </div> + ); + } + + if (provider.length < 1) { + provider = decodeIDNA(getHostname(card.get('url'))); + } + + const className = classnames('status-card', { + 'horizontal': card.get('width') > card.get('height'), + }); + + return ( + <a href={card.get('url')} className={className} target='_blank' rel='noopener'> + {image} + + <div className='status-card__content'> + <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong> + <p className='status-card__description'>{(card.get('description') || '').substring(0, maxDescription)}</p> + <span className='status-card__host'>{provider}</span> + </div> + </a> + ); + } + + renderPhoto () { + const { card } = this.props; + + return ( + <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'> + <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} /> + </a> + ); + } + + setRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + + renderVideo () { + 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 }} + /> + ); + } + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + switch(card.get('type')) { + case 'link': + return this.renderLink(); + case 'photo': + return this.renderPhoto(); + case 'video': + return this.renderVideo(); + case 'rich': + default: + return null; + } + } + +} diff --git a/app/javascript/themes/glitch/features/status/components/detailed_status.js b/app/javascript/themes/glitch/features/status/components/detailed_status.js new file mode 100644 index 000000000..7606bfbf3 --- /dev/null +++ b/app/javascript/themes/glitch/features/status/components/detailed_status.js @@ -0,0 +1,128 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'themes/glitch/components/avatar'; +import DisplayName from 'themes/glitch/components/display_name'; +import StatusContent from 'themes/glitch/components/status_content'; +import StatusGallery from 'themes/glitch/components/media_gallery'; +import AttachmentList from 'themes/glitch/components/attachment_list'; +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'; +import Video from 'themes/glitch/features/video'; +import VisibilityIcon from 'themes/glitch/components/status_visibility_icon'; + +export default class DetailedStatus extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + settings: ImmutablePropTypes.map.isRequired, + onOpenMedia: PropTypes.func.isRequired, + onOpenVideo: PropTypes.func.isRequired, + }; + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + + e.stopPropagation(); + } + + // handleOpenVideo = startTime => { + // this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + // } + + render () { + const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; + const { settings } = this.props; + + let media = ''; + let mediaIcon = null; + let applicationLink = ''; + let reblogLink = ''; + let reblogIcon = 'retweet'; + + if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + media = <AttachmentList media={status.get('media_attachments')} />; + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + media = ( + <Video + sensitive={status.get('sensitive')} + media={status.getIn(['media_attachments', 0])} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + onOpenVideo={this.props.onOpenVideo} + autoplay + /> + ); + mediaIcon = 'video-camera'; + } else { + media = ( + <StatusGallery + sensitive={status.get('sensitive')} + media={status.get('media_attachments')} + letterbox={settings.getIn(['media', 'letterbox'])} + onOpenMedia={this.props.onOpenMedia} + /> + ); + mediaIcon = 'picture-o'; + } + } else media = <CardContainer statusId={status.get('id')} />; + + if (status.get('application')) { + applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; + } + + if (status.get('visibility') === 'direct') { + reblogIcon = 'envelope'; + } else if (status.get('visibility') === 'private') { + reblogIcon = 'lock'; + } + + if (status.get('visibility') === 'private') { + reblogLink = <i className={`fa fa-${reblogIcon}`} />; + } else { + reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> + <i className={`fa fa-${reblogIcon}`} /> + <span className='detailed-status__reblogs'> + <FormattedNumber value={status.get('reblogs_count')} /> + </span> + </Link>); + } + + return ( + <div className='detailed-status'> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div> + <DisplayName account={status.get('account')} /> + </a> + + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + /> + + <div className='detailed-status__meta'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> + <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> + </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> + <i className='fa fa-star' /> + <span className='detailed-status__favorites'> + <FormattedNumber value={status.get('favourites_count')} /> + </span> + </Link> · <VisibilityIcon visibility={status.get('visibility')} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/status/containers/card_container.js b/app/javascript/themes/glitch/features/status/containers/card_container.js new file mode 100644 index 000000000..a97404de1 --- /dev/null +++ b/app/javascript/themes/glitch/features/status/containers/card_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Card from '../components/card'; + +const mapStateToProps = (state, { statusId }) => ({ + card: state.getIn(['cards', statusId], null), +}); + +export default connect(mapStateToProps)(Card); diff --git a/app/javascript/themes/glitch/features/status/index.js b/app/javascript/themes/glitch/features/status/index.js new file mode 100644 index 000000000..57af94a9a --- /dev/null +++ b/app/javascript/themes/glitch/features/status/index.js @@ -0,0 +1,343 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchStatus } from 'themes/glitch/actions/statuses'; +import MissingIndicator from 'themes/glitch/components/missing_indicator'; +import DetailedStatus from './components/detailed_status'; +import ActionBar from './components/action_bar'; +import Column from 'themes/glitch/features/ui/components/column'; +import { + favourite, + unfavourite, + reblog, + unreblog, + pin, + unpin, +} from 'themes/glitch/actions/interactions'; +import { + replyCompose, + mentionCompose, +} from 'themes/glitch/actions/compose'; +import { deleteStatus } from 'themes/glitch/actions/statuses'; +import { initReport } from 'themes/glitch/actions/reports'; +import { makeGetStatus } from 'themes/glitch/selectors'; +import { ScrollContainer } from 'react-router-scroll-4'; +import ColumnBackButton from 'themes/glitch/components/column_back_button'; +import StatusContainer from 'themes/glitch/containers/status_container'; +import { openModal } from 'themes/glitch/actions/modal'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; +import { boostModal, deleteModal } from 'themes/glitch/util/initial_state'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'themes/glitch/util/fullscreen'; + +const messages = defineMessages({ + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props.params.statusId), + settings: state.get('local_settings'), + ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]), + descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]), + }); + + return mapStateToProps; +}; + +@injectIntl +@connect(makeMapStateToProps) +export default class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + status: ImmutablePropTypes.map, + settings: ImmutablePropTypes.map.isRequired, + ancestorsIds: ImmutablePropTypes.list, + descendantsIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + state = { + fullscreen: false, + }; + + componentWillMount () { + this.props.dispatch(fetchStatus(this.props.params.statusId)); + } + + componentDidMount () { + attachFullscreenListener(this.onFullScreenChange); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this._scrolledIntoView = false; + this.props.dispatch(fetchStatus(nextProps.params.statusId)); + } + } + + handleFavouriteClick = (status) => { + if (status.get('favourited')) { + this.props.dispatch(unfavourite(status)); + } else { + this.props.dispatch(favourite(status)); + } + } + + handlePin = (status) => { + if (status.get('pinned')) { + this.props.dispatch(unpin(status)); + } else { + this.props.dispatch(pin(status)); + } + } + + handleReplyClick = (status) => { + this.props.dispatch(replyCompose(status, this.context.router.history)); + } + + handleModalReblog = (status) => { + this.props.dispatch(reblog(status)); + } + + handleReblogClick = (status, e) => { + if (status.get('reblogged')) { + this.props.dispatch(unreblog(status)); + } else { + if (e.shiftKey || !boostModal) { + this.handleModalReblog(status); + } else { + this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); + } + } + } + + handleDeleteClick = (status) => { + const { dispatch, intl } = this.props; + + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'))), + })); + } + } + + handleMentionClick = (account, router) => { + this.props.dispatch(mentionCompose(account, router)); + } + + handleOpenMedia = (media, index) => { + this.props.dispatch(openModal('MEDIA', { media, index })); + } + + handleOpenVideo = (media, time) => { + this.props.dispatch(openModal('VIDEO', { media, time })); + } + + handleReport = (status) => { + this.props.dispatch(initReport(status.get('account'), status)); + } + + handleEmbed = (status) => { + 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} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); + } + + setRef = c => { + this.node = c; + } + + componentDidUpdate () { + if (this._scrolledIntoView) { + return; + } + + const { status, ancestorsIds } = this.props; + + if (status && ancestorsIds && ancestorsIds.size > 0) { + const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1]; + + if (element) { + element.scrollIntoView(true); + this._scrolledIntoView = true; + } + } + } + + componentWillUnmount () { + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + render () { + let ancestors, descendants; + const { status, settings, ancestorsIds, descendantsIds } = this.props; + const { fullscreen } = this.state; + + if (status === null) { + return ( + <Column> + <ColumnBackButton /> + <MissingIndicator /> + </Column> + ); + } + + if (ancestorsIds && ancestorsIds.size > 0) { + ancestors = <div>{this.renderChildren(ancestorsIds)}</div>; + } + + if (descendantsIds && descendantsIds.size > 0) { + 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={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}> + {ancestors} + + <HotKeys handlers={handlers}> + <div className='focusable' tabIndex='0'> + <DetailedStatus + status={status} + settings={settings} + onOpenVideo={this.handleOpenVideo} + onOpenMedia={this.handleOpenMedia} + /> + + <ActionBar + status={status} + 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> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/actions_modal.js b/app/javascript/themes/glitch/features/ui/components/actions_modal.js new file mode 100644 index 000000000..7a2b78b63 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/actions_modal.js @@ -0,0 +1,74 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import StatusContent from 'themes/glitch/components/status_content'; +import Avatar from 'themes/glitch/components/avatar'; +import RelativeTimestamp from 'themes/glitch/components/relative_timestamp'; +import DisplayName from 'themes/glitch/components/display_name'; +import IconButton from 'themes/glitch/components/icon_button'; +import classNames from 'classnames'; + +export default class ActionsModal extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + actions: PropTypes.array, + onClick: PropTypes.func, + }; + + renderAction = (action, i) => { + if (action === null) { + return <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { icon = null, text, meta = null, active = false, href = '#' } = action; + + return ( + <li key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}> + {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />} + <div> + <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div> + <div>{meta}</div> + </div> + </a> + </li> + ); + } + + render () { + const status = this.props.status && ( + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> + <RelativeTimestamp timestamp={this.props.status.get('created_at')} /> + </a> + </div> + + <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={this.props.status.get('account')} size={48} /> + </div> + + <DisplayName account={this.props.status.get('account')} /> + </a> + </div> + + <StatusContent status={this.props.status} /> + </div> + ); + + return ( + <div className='modal-root__modal actions-modal'> + {status} + + <ul> + {this.props.actions.map(this.renderAction)} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/boost_modal.js b/app/javascript/themes/glitch/features/ui/components/boost_modal.js new file mode 100644 index 000000000..49781db10 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/boost_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'themes/glitch/components/button'; +import StatusContent from 'themes/glitch/components/status_content'; +import Avatar from 'themes/glitch/components/avatar'; +import RelativeTimestamp from 'themes/glitch/components/relative_timestamp'; +import DisplayName from 'themes/glitch/components/display_name'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, +}); + +@injectIntl +export default class BoostModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleReblog = () => { + this.props.onReblog(this.props.status); + this.props.onClose(); + } + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { status, intl } = this.props; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={status.get('account')} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} /> + </div> + </div> + + <div className='boost-modal__action-bar'> + <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div> + <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} ref={this.setRef} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/bundle.js b/app/javascript/themes/glitch/features/ui/components/bundle.js new file mode 100644 index 000000000..fc88e0c70 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/bundle.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const emptyComponent = () => null; +const noop = () => { }; + +class Bundle extends React.Component { + + static propTypes = { + fetchComponent: PropTypes.func.isRequired, + loading: PropTypes.func, + error: PropTypes.func, + children: PropTypes.func.isRequired, + renderDelay: PropTypes.number, + onFetch: PropTypes.func, + onFetchSuccess: PropTypes.func, + onFetchFail: PropTypes.func, + } + + static defaultProps = { + loading: emptyComponent, + error: emptyComponent, + renderDelay: 0, + onFetch: noop, + onFetchSuccess: noop, + onFetchFail: noop, + } + + static cache = {} + + state = { + mod: undefined, + forceRender: false, + } + + componentWillMount() { + this.load(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.fetchComponent !== this.props.fetchComponent) { + this.load(nextProps); + } + } + + componentWillUnmount () { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + load = (props) => { + const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + + onFetch(); + + if (Bundle.cache[fetchComponent.name]) { + const mod = Bundle.cache[fetchComponent.name]; + + this.setState({ mod: mod.default }); + onFetchSuccess(); + return Promise.resolve(); + } + + this.setState({ mod: undefined }); + + if (renderDelay !== 0) { + this.timestamp = new Date(); + this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay); + } + + return fetchComponent() + .then((mod) => { + Bundle.cache[fetchComponent.name] = mod; + this.setState({ mod: mod.default }); + onFetchSuccess(); + }) + .catch((error) => { + this.setState({ mod: null }); + onFetchFail(error); + }); + } + + render() { + const { loading: Loading, error: Error, children, renderDelay } = this.props; + const { mod, forceRender } = this.state; + const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay; + + if (mod === undefined) { + return (elapsed >= renderDelay || forceRender) ? <Loading /> : null; + } + + if (mod === null) { + return <Error onRetry={this.load} />; + } + + return children(mod); + } + +} + +export default Bundle; diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js new file mode 100644 index 000000000..daedc6299 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import Column from './column'; +import ColumnHeader from './column_header'; +import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim'; +import IconButton from 'themes/glitch/components/icon_button'; + +const messages = defineMessages({ + title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' }, + body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, +}); + +class BundleColumnError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { intl: { formatMessage } } = this.props; + + return ( + <Column> + <ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} /> + <ColumnBackButtonSlim /> + <div className='error-column'> + <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> + {formatMessage(messages.body)} + </div> + </Column> + ); + } + +} + +export default injectIntl(BundleColumnError); diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js new file mode 100644 index 000000000..8cca32ae9 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import IconButton from 'themes/glitch/components/icon_button'; + +const messages = defineMessages({ + error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, + close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, +}); + +class BundleModalError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { onClose, intl: { formatMessage } } = this.props; + + // Keep the markup in sync with <ModalLoading /> + // (make sure they have the same dimensions) + return ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> + {formatMessage(messages.error)} + </div> + + <div className='error-modal__footer'> + <div> + <button + onClick={onClose} + className='error-modal__nav onboarding-modal__skip' + > + {formatMessage(messages.close)} + </button> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(BundleModalError); diff --git a/app/javascript/themes/glitch/features/ui/components/column.js b/app/javascript/themes/glitch/features/ui/components/column.js new file mode 100644 index 000000000..73a5bc15e --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column.js @@ -0,0 +1,74 @@ +import React from 'react'; +import ColumnHeader from './column_header'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; +import { scrollTop } from 'themes/glitch/util/scroll'; +import { isMobile } from 'themes/glitch/util/is_mobile'; + +export default class Column extends React.PureComponent { + + static propTypes = { + heading: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.node, + active: PropTypes.bool, + hideHeadingOnMobile: PropTypes.bool, + name: PropTypes.string, + }; + + handleHeaderClick = () => { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + + handleScroll = debounce(() => { + if (typeof this._interruptScrollAnimation !== 'undefined') { + this._interruptScrollAnimation(); + } + }, 200) + + setRef = (c) => { + this.node = c; + } + + render () { + const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props; + + const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth))); + + const columnHeaderId = showHeading && heading.replace(/ /g, '-'); + const header = showHeading && ( + <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} /> + ); + return ( + <div + ref={this.setRef} + role='region' + data-column={name} + aria-labelledby={columnHeaderId} + className='column' + onScroll={this.handleScroll} + > + {header} + {children} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/column_header.js b/app/javascript/themes/glitch/features/ui/components/column_header.js new file mode 100644 index 000000000..af195ea9c --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_header.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class ColumnHeader extends React.PureComponent { + + static propTypes = { + icon: PropTypes.string, + type: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func, + columnHeaderId: PropTypes.string, + }; + + handleClick = () => { + this.props.onClick(); + } + + render () { + const { type, active, columnHeaderId } = this.props; + + let icon = ''; + + if (this.props.icon) { + icon = <i className={`fa fa-fw fa-${this.props.icon} column-header__icon`} />; + } + + return ( + <div role='heading' tabIndex='0' className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}> + {icon} + {type} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/column_link.js b/app/javascript/themes/glitch/features/ui/components/column_link.js new file mode 100644 index 000000000..b845d1895 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_link.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; + +const ColumnLink = ({ icon, text, to, onClick, href, method }) => { + if (href) { + return ( + <a href={href} className='column-link' data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </a> + ); + } else if (to) { + return ( + <Link to={to} className='column-link'> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </Link> + ); + } else { + return ( + <a onClick={onClick} className='column-link' role='button' tabIndex='0' data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </a> + ); + } +}; + +ColumnLink.propTypes = { + icon: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + to: PropTypes.string, + onClick: PropTypes.func, + href: PropTypes.string, + method: PropTypes.string, +}; + +export default ColumnLink; diff --git a/app/javascript/themes/glitch/features/ui/components/column_loading.js b/app/javascript/themes/glitch/features/ui/components/column_loading.js new file mode 100644 index 000000000..75f26218a --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_loading.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Column from 'themes/glitch/components/column'; +import ColumnHeader from 'themes/glitch/components/column_header'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class ColumnLoading extends ImmutablePureComponent { + + static propTypes = { + title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + icon: PropTypes.string, + }; + + static defaultProps = { + title: '', + icon: '', + }; + + render() { + let { title, icon } = this.props; + return ( + <Column> + <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} /> + <div className='scrollable' /> + </Column> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/column_subheading.js b/app/javascript/themes/glitch/features/ui/components/column_subheading.js new file mode 100644 index 000000000..8160c4aa3 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/column_subheading.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const ColumnSubheading = ({ text }) => { + return ( + <div className='column-subheading'> + {text} + </div> + ); +}; + +ColumnSubheading.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default ColumnSubheading; diff --git a/app/javascript/themes/glitch/features/ui/components/columns_area.js b/app/javascript/themes/glitch/features/ui/components/columns_area.js new file mode 100644 index 000000000..452950363 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/columns_area.js @@ -0,0 +1,174 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import ReactSwipeableViews from 'react-swipeable-views'; +import { links, getIndex, getLink } from './tabs_bar'; + +import BundleContainer from '../containers/bundle_container'; +import ColumnLoading from './column_loading'; +import DrawerLoading from './drawer_loading'; +import BundleColumnError from './bundle_column_error'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from 'themes/glitch/util/async-components'; + +import detectPassiveEvents from 'detect-passive-events'; +import { scrollRight } from 'themes/glitch/util/scroll'; + +const componentMap = { + 'COMPOSE': Compose, + 'HOME': HomeTimeline, + 'NOTIFICATIONS': Notifications, + 'PUBLIC': PublicTimeline, + 'COMMUNITY': CommunityTimeline, + 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, + 'FAVOURITES': FavouritedStatuses, +}; + +@component => injectIntl(component, { withRef: true }) +export default class ColumnsArea extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + columns: ImmutablePropTypes.list.isRequired, + singleColumn: PropTypes.bool, + children: PropTypes.node, + }; + + state = { + shouldAnimate: false, + } + + componentWillReceiveProps() { + this.setState({ shouldAnimate: false }); + } + + componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + + componentWillUpdate(nextProps) { + if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + componentDidUpdate(prevProps) { + if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth); + } + } + + handleSwipe = (index) => { + this.pendingIndex = index; + + const nextLinkTranslationId = links[index].props['data-preview-title-id']; + const currentLinkSelector = '.tabs-bar__link.active'; + const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`; + + // HACK: Remove the active class from the current link and set it to the next one + // React-router does this for us, but too late, feeling laggy. + document.querySelector(currentLinkSelector).classList.remove('active'); + document.querySelector(nextLinkSelector).classList.add('active'); + } + + handleAnimationEnd = () => { + if (typeof this.pendingIndex === 'number') { + this.context.router.history.push(getLink(this.pendingIndex)); + this.pendingIndex = null; + } + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = (node) => { + this.node = node; + } + + renderView = (link, index) => { + const columnIndex = getIndex(this.context.router.history.location.pathname); + const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] }); + const icon = link.props['data-preview-icon']; + + const view = (index === columnIndex) ? + React.cloneElement(this.props.children) : + <ColumnLoading title={title} icon={icon} />; + + return ( + <div className='columns-area' key={index}> + {view} + </div> + ); + } + + renderLoading = columnId => () => { + return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />; + } + + renderError = (props) => { + return <BundleColumnError {...props} />; + } + + render () { + const { columns, children, singleColumn } = this.props; + const { shouldAnimate } = this.state; + + const columnIndex = getIndex(this.context.router.history.location.pathname); + this.pendingIndex = null; + + if (singleColumn) { + return columnIndex !== -1 ? ( + <ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}> + {links.map(this.renderView)} + </ReactSwipeableViews> + ) : <div className='columns-area'>{children}</div>; + } + + return ( + <div className='columns-area' ref={this.setRef}> + {columns.map(column => { + const params = column.get('params', null) === null ? null : column.get('params').toJS(); + + return ( + <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}> + {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />} + </BundleContainer> + ); + })} + + {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js new file mode 100644 index 000000000..3d568aec3 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'themes/glitch/components/button'; + +@injectIntl +export default class ConfirmationModal extends React.PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + confirm: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { message, confirm } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + {message} + </div> + + <div className='confirmation-modal__action-bar'> + <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button text={confirm} onClick={this.handleClick} ref={this.setRef} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/doodle_modal.js b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js new file mode 100644 index 000000000..819656dbf --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js @@ -0,0 +1,614 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'themes/glitch/components/button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Atrament from 'atrament'; // the doodling library +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { doodleSet, uploadCompose } from 'themes/glitch/actions/compose'; +import IconButton from 'themes/glitch/components/icon_button'; +import { debounce, mapValues } from 'lodash'; +import classNames from 'classnames'; + +// palette nicked from MyPaint, CC0 +const palette = [ + ['rgb( 0, 0, 0)', 'Black'], + ['rgb( 38, 38, 38)', 'Gray 15'], + ['rgb( 77, 77, 77)', 'Grey 30'], + ['rgb(128, 128, 128)', 'Grey 50'], + ['rgb(171, 171, 171)', 'Grey 67'], + ['rgb(217, 217, 217)', 'Grey 85'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(128, 0, 0)', 'Maroon'], + ['rgb(209, 0, 0)', 'English-red'], + ['rgb(255, 54, 34)', 'Tomato'], + ['rgb(252, 60, 3)', 'Orange-red'], + ['rgb(255, 140, 105)', 'Salmon'], + ['rgb(252, 232, 32)', 'Cadium-yellow'], + ['rgb(243, 253, 37)', 'Lemon yellow'], + ['rgb(121, 5, 35)', 'Dark crimson'], + ['rgb(169, 32, 62)', 'Deep carmine'], + ['rgb(255, 140, 0)', 'Orange'], + ['rgb(255, 168, 18)', 'Dark tangerine'], + ['rgb(217, 144, 88)', 'Persian orange'], + ['rgb(194, 178, 128)', 'Sand'], + ['rgb(255, 229, 180)', 'Peach'], + ['rgb(100, 54, 46)', 'Bole'], + ['rgb(108, 41, 52)', 'Dark cordovan'], + ['rgb(163, 65, 44)', 'Chestnut'], + ['rgb(228, 136, 100)', 'Dark salmon'], + ['rgb(255, 195, 143)', 'Apricot'], + ['rgb(255, 219, 188)', 'Unbleached silk'], + ['rgb(242, 227, 198)', 'Straw'], + ['rgb( 53, 19, 13)', 'Bistre'], + ['rgb( 84, 42, 14)', 'Dark chocolate'], + ['rgb(102, 51, 43)', 'Burnt sienna'], + ['rgb(184, 66, 0)', 'Sienna'], + ['rgb(216, 153, 12)', 'Yellow ochre'], + ['rgb(210, 180, 140)', 'Tan'], + ['rgb(232, 204, 144)', 'Dark wheat'], + ['rgb( 0, 49, 83)', 'Prussian blue'], + ['rgb( 48, 69, 119)', 'Dark grey blue'], + ['rgb( 0, 71, 171)', 'Cobalt blue'], + ['rgb( 31, 117, 254)', 'Blue'], + ['rgb(120, 180, 255)', 'Bright french blue'], + ['rgb(171, 200, 255)', 'Bright steel blue'], + ['rgb(208, 231, 255)', 'Ice blue'], + ['rgb( 30, 51, 58)', 'Medium jungle green'], + ['rgb( 47, 79, 79)', 'Dark slate grey'], + ['rgb( 74, 104, 93)', 'Dark grullo green'], + ['rgb( 0, 128, 128)', 'Teal'], + ['rgb( 67, 170, 176)', 'Turquoise'], + ['rgb(109, 174, 199)', 'Cerulean frost'], + ['rgb(173, 217, 186)', 'Tiffany green'], + ['rgb( 22, 34, 29)', 'Gray-asparagus'], + ['rgb( 36, 48, 45)', 'Medium dark teal'], + ['rgb( 74, 104, 93)', 'Xanadu'], + ['rgb(119, 198, 121)', 'Mint'], + ['rgb(175, 205, 182)', 'Timberwolf'], + ['rgb(185, 245, 246)', 'Celeste'], + ['rgb(193, 255, 234)', 'Aquamarine'], + ['rgb( 29, 52, 35)', 'Cal Poly Pomona'], + ['rgb( 1, 68, 33)', 'Forest green'], + ['rgb( 42, 128, 0)', 'Napier green'], + ['rgb(128, 128, 0)', 'Olive'], + ['rgb( 65, 156, 105)', 'Sea green'], + ['rgb(189, 246, 29)', 'Green-yellow'], + ['rgb(231, 244, 134)', 'Bright chartreuse'], + ['rgb(138, 23, 137)', 'Purple'], + ['rgb( 78, 39, 138)', 'Violet'], + ['rgb(193, 75, 110)', 'Dark thulian pink'], + ['rgb(222, 49, 99)', 'Cerise'], + ['rgb(255, 20, 147)', 'Deep pink'], + ['rgb(255, 102, 204)', 'Rose pink'], + ['rgb(255, 203, 219)', 'Pink'], + ['rgb(255, 255, 255)', 'White'], + ['rgb(229, 17, 1)', 'RGB Red'], + ['rgb( 0, 255, 0)', 'RGB Green'], + ['rgb( 0, 0, 255)', 'RGB Blue'], + ['rgb( 0, 255, 255)', 'CMYK Cyan'], + ['rgb(255, 0, 255)', 'CMYK Magenta'], + ['rgb(255, 255, 0)', 'CMYK Yellow'], +]; + +// re-arrange to the right order for display +let palReordered = []; +for (let row = 0; row < 7; row++) { + for (let col = 0; col < 11; col++) { + palReordered.push(palette[col * 7 + row]); + } + palReordered.push(null); // null indicates a <br /> +} + +// Utility for converting base64 image to binary for upload +// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f +function dataURLtoFile(dataurl, filename) { + let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); + while(n--){ + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mime }); +} + +const DOODLE_SIZES = { + normal: [500, 500, 'Square 500'], + tootbanner: [702, 330, 'Tootbanner'], + s640x480: [640, 480, '640×480 - 480p'], + s800x600: [800, 600, '800×600 - SVGA'], + s720x480: [720, 405, '720x405 - 16:9'], +}; + + +const mapStateToProps = state => ({ + options: state.getIn(['compose', 'doodle']), +}); + +const mapDispatchToProps = dispatch => ({ + /** Set options in the redux store */ + setOpt: (opts) => dispatch(doodleSet(opts)), + /** Submit doodle for upload */ + submit: (file) => dispatch(uploadCompose([file])), +}); + +/** + * Doodling dialog with drawing canvas + * + * Keyboard shortcuts: + * - Delete: Clear screen, fill with background color + * - Backspace, Ctrl+Z: Undo one step + * - Ctrl held while drawing: Use background color + * - Shift held while clicking screen: Use fill tool + * + * Palette: + * - Left mouse button: pick foreground + * - Ctrl + left mouse button: pick background + * - Right mouse button: pick background + */ +@connect(mapStateToProps, mapDispatchToProps) +export default class DoodleModal extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.map, + onClose: PropTypes.func.isRequired, + setOpt: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, + }; + + //region Option getters/setters + + /** Foreground color */ + get fg () { + return this.props.options.get('fg'); + } + set fg (value) { + this.props.setOpt({ fg: value }); + } + + /** Background color */ + get bg () { + return this.props.options.get('bg'); + } + set bg (value) { + this.props.setOpt({ bg: value }); + } + + /** Swap Fg and Bg for drawing */ + get swapped () { + return this.props.options.get('swapped'); + } + set swapped (value) { + this.props.setOpt({ swapped: value }); + } + + /** Mode - 'draw' or 'fill' */ + get mode () { + return this.props.options.get('mode'); + } + set mode (value) { + this.props.setOpt({ mode: value }); + } + + /** Base line weight */ + get weight () { + return this.props.options.get('weight'); + } + set weight (value) { + this.props.setOpt({ weight: value }); + } + + /** Drawing opacity */ + get opacity () { + return this.props.options.get('opacity'); + } + set opacity (value) { + this.props.setOpt({ opacity: value }); + } + + /** Adaptive stroke - change width with speed */ + get adaptiveStroke () { + return this.props.options.get('adaptiveStroke'); + } + set adaptiveStroke (value) { + this.props.setOpt({ adaptiveStroke: value }); + } + + /** Smoothing (for mouse drawing) */ + get smoothing () { + return this.props.options.get('smoothing'); + } + set smoothing (value) { + this.props.setOpt({ smoothing: value }); + } + + /** Size preset */ + get size () { + return this.props.options.get('size'); + } + set size (value) { + this.props.setOpt({ size: value }); + } + + //endregion + + /** Key up handler */ + handleKeyUp = (e) => { + if (e.target.nodeName === 'INPUT') return; + + if (e.key === 'Delete') { + e.preventDefault(); + this.handleClearBtn(); + return; + } + + if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + this.undo(); + } + + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = false; + this.swapped = false; + } + + if (e.key === 'Shift') { + this.shiftHeld = false; + this.mode = 'draw'; + } + }; + + /** Key down handler */ + handleKeyDown = (e) => { + if (e.key === 'Control' || e.key === 'Meta') { + this.controlHeld = true; + this.swapped = true; + } + + if (e.key === 'Shift') { + this.shiftHeld = true; + this.mode = 'fill'; + } + }; + + /** + * Component installed in the DOM, do some initial set-up + */ + componentDidMount () { + this.controlHeld = false; + this.shiftHeld = false; + this.swapped = false; + window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); + }; + + /** + * Tear component down + */ + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp, false); + window.removeEventListener('keydown', this.handleKeyDown, false); + if (this.sketcher) this.sketcher.destroy(); + } + + /** + * Set reference to the canvas element. + * This is called during component init + * + * @param elem - canvas element + */ + setCanvasRef = (elem) => { + this.canvas = elem; + if (elem) { + elem.addEventListener('dirty', () => { + this.saveUndo(); + this.sketcher._dirty = false; + }); + + elem.addEventListener('click', () => { + // sketcher bug - does not fire dirty on fill + if (this.mode === 'fill') { + this.saveUndo(); + } + }); + + // prevent context menu + elem.addEventListener('contextmenu', (e) => { + e.preventDefault(); + }); + + elem.addEventListener('mousedown', (e) => { + if (e.button === 2) { + this.swapped = true; + } + }); + + elem.addEventListener('mouseup', (e) => { + if (e.button === 2) { + this.swapped = this.controlHeld; + } + }); + + this.initSketcher(elem); + this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill' + } + }; + + /** + * Set up the sketcher instance + * + * @param canvas - canvas element. Null if we're just resizing + */ + initSketcher (canvas = null) { + const sizepreset = DOODLE_SIZES[this.size]; + + if (this.sketcher) this.sketcher.destroy(); + this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]); + + if (canvas) { + this.ctx = this.sketcher.context; + this.updateSketcherSettings(); + } + + this.clearScreen(); + } + + /** + * Done button handler + */ + onDoneButton = () => { + const dataUrl = this.sketcher.toImage(); + const file = dataURLtoFile(dataUrl, 'doodle.png'); + this.props.submit(file); + this.props.onClose(); // close dialog + }; + + /** + * Cancel button handler + */ + onCancelButton = () => { + if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) { + return; + } + + this.props.onClose(); // close dialog + }; + + /** + * Update sketcher options based on state + */ + updateSketcherSettings () { + if (!this.sketcher) return; + + if (this.oldSize !== this.size) this.initSketcher(); + + this.sketcher.color = (this.swapped ? this.bg : this.fg); + this.sketcher.opacity = this.opacity; + this.sketcher.weight = this.weight; + this.sketcher.mode = this.mode; + this.sketcher.smoothing = this.smoothing; + this.sketcher.adaptiveStroke = this.adaptiveStroke; + + this.oldSize = this.size; + } + + /** + * Fill screen with background color + */ + clearScreen = () => { + this.ctx.fillStyle = this.bg; + this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2); + this.undos = []; + + this.doSaveUndo(); + }; + + /** + * Undo one step + */ + undo = () => { + if (this.undos.length > 1) { + this.undos.pop(); + const buf = this.undos.pop(); + + this.sketcher.clear(); + this.ctx.putImageData(buf, 0, 0); + this.doSaveUndo(); + } + }; + + /** + * Save canvas content into the undo buffer immediately + */ + doSaveUndo = () => { + this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)); + }; + + /** + * Called on each canvas change. + * Saves canvas content to the undo buffer after some period of inactivity. + */ + saveUndo = debounce(() => { + this.doSaveUndo(); + }, 100); + + /** + * Palette left click. + * Selects Fg color (or Bg, if Control/Meta is held) + * + * @param e - event + */ + onPaletteClick = (e) => { + const c = e.target.dataset.color; + + if (this.controlHeld) { + this.bg = c; + } else { + this.fg = c; + } + + e.target.blur(); + e.preventDefault(); + }; + + /** + * Palette right click. + * Selects Bg color + * + * @param e - event + */ + onPaletteRClick = (e) => { + this.bg = e.target.dataset.color; + e.target.blur(); + e.preventDefault(); + }; + + /** + * Handle click on the Draw mode button + * + * @param e - event + */ + setModeDraw = (e) => { + this.mode = 'draw'; + e.target.blur(); + }; + + /** + * Handle click on the Fill mode button + * + * @param e - event + */ + setModeFill = (e) => { + this.mode = 'fill'; + e.target.blur(); + }; + + /** + * Handle click on Smooth checkbox + * + * @param e - event + */ + tglSmooth = (e) => { + this.smoothing = !this.smoothing; + e.target.blur(); + }; + + /** + * Handle click on Adaptive checkbox + * + * @param e - event + */ + tglAdaptive = (e) => { + this.adaptiveStroke = !this.adaptiveStroke; + e.target.blur(); + }; + + /** + * Handle change of the Weight input field + * + * @param e - event + */ + setWeight = (e) => { + this.weight = +e.target.value || 1; + }; + + /** + * Set size - clalback from the select box + * + * @param e - event + */ + changeSize = (e) => { + let newSize = e.target.value; + if (newSize === this.oldSize) return; + + if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) { + return; + } + + this.size = newSize; + }; + + handleClearBtn = () => { + if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) { + return; + } + + this.clearScreen(); + }; + + /** + * Render the component + */ + render () { + this.updateSketcherSettings(); + + return ( + <div className='modal-root__modal doodle-modal'> + <div className='doodle-modal__container'> + <canvas ref={this.setCanvasRef} /> + </div> + + <div className='doodle-modal__action-bar'> + <div className='doodle-toolbar'> + <Button text='Done' onClick={this.onDoneButton} /> + <Button text='Cancel' onClick={this.onCancelButton} /> + </div> + <div className='filler' /> + <div className='doodle-toolbar with-inputs'> + <div> + <label htmlFor='dd_smoothing'>Smoothing</label> + <span className='val'> + <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} /> + </span> + </div> + <div> + <label htmlFor='dd_adaptive'>Adaptive</label> + <span className='val'> + <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} /> + </span> + </div> + <div> + <label htmlFor='dd_weight'>Weight</label> + <span className='val'> + <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} /> + </span> + </div> + <div> + <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}> + { Object.values(mapValues(DOODLE_SIZES, (val, k) => + <option key={k} value={k}>{val[2]}</option> + )) } + </select> + </div> + </div> + <div className='doodle-toolbar'> + <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted /> + <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted /> + <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted /> + <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted /> + </div> + <div className='doodle-palette'> + { + palReordered.map((c, i) => + c === null ? + <br key={i} /> : + <button + key={i} + style={{ backgroundColor: c[0] }} + onClick={this.onPaletteClick} + onContextMenu={this.onPaletteRClick} + data-color={c[0]} + title={c[1]} + className={classNames({ + 'foreground': this.fg === c[0], + 'background': this.bg === c[0], + })} + /> + ) + } + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/drawer_loading.js b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js new file mode 100644 index 000000000..08b0d2347 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js @@ -0,0 +1,11 @@ +import React from 'react'; + +const DrawerLoading = () => ( + <div className='drawer'> + <div className='drawer__pager'> + <div className='drawer__inner' /> + </div> + </div> +); + +export default DrawerLoading; diff --git a/app/javascript/themes/glitch/features/ui/components/embed_modal.js b/app/javascript/themes/glitch/features/ui/components/embed_modal.js new file mode 100644 index 000000000..1afffb51b --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/embed_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import axios from 'axios'; + +@injectIntl +export default class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + url: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { url } = this.props; + + this.setState({ loading: true }); + + axios.post('/api/web/embed', { url }).then(res => { + this.setState({ loading: false, oembed: res.data }); + + const iframeDocument = this.iframe.contentWindow.document; + + iframeDocument.open(); + iframeDocument.write(res.data.html); + iframeDocument.close(); + + iframeDocument.body.style.margin = 0; + this.iframe.width = iframeDocument.body.scrollWidth; + this.iframe.height = iframeDocument.body.scrollHeight; + }); + } + + setIframeRef = c => { + this.iframe = c; + } + + handleTextareaClick = (e) => { + e.target.select(); + } + + render () { + const { oembed } = this.state; + + return ( + <div className='modal-root__modal embed-modal'> + <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4> + + <div className='embed-modal__container'> + <p className='hint'> + <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> + </p> + + <input + type='text' + className='embed-modal__html' + readOnly + value={oembed && oembed.html || ''} + onClick={this.handleTextareaClick} + /> + + <p className='hint'> + <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' /> + </p> + + <iframe + className='embed-modal__iframe' + frameBorder='0' + ref={this.setIframeRef} + title='preview' + /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/image_loader.js b/app/javascript/themes/glitch/features/ui/components/image_loader.js new file mode 100644 index 000000000..aad594380 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/image_loader.js @@ -0,0 +1,152 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class ImageLoader extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + previewSrc: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + } + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + loading: true, + error: false, + } + + removers = []; + + get canvasContext() { + if (!this.canvas) { + return null; + } + this._canvasContext = this._canvasContext || this.canvas.getContext('2d'); + return this._canvasContext; + } + + componentDidMount () { + this.loadImage(this.props); + } + + componentWillReceiveProps (nextProps) { + if (this.props.src !== nextProps.src) { + this.loadImage(nextProps); + } + } + + loadImage (props) { + this.removeEventListeners(); + this.setState({ loading: true, error: false }); + Promise.all([ + this.loadPreviewCanvas(props), + this.hasSize() && this.loadOriginalImage(props), + ].filter(Boolean)) + .then(() => { + this.setState({ loading: false, error: false }); + this.clearPreviewCanvas(); + }) + .catch(() => this.setState({ loading: false, error: true })); + } + + loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => { + const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + this.canvasContext.drawImage(image, 0, 0, width, height); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = previewSrc; + this.removers.push(removeEventListeners); + }) + + clearPreviewCanvas () { + const { width, height } = this.canvas; + this.canvasContext.clearRect(0, 0, width, height); + } + + loadOriginalImage = ({ src }) => new Promise((resolve, reject) => { + const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = src; + this.removers.push(removeEventListeners); + }); + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + hasSize () { + const { width, height } = this.props; + return typeof width === 'number' && typeof height === 'number'; + } + + setCanvasRef = c => { + this.canvas = c; + } + + render () { + const { alt, src, width, height } = this.props; + const { loading } = this.state; + + const className = classNames('image-loader', { + 'image-loader--loading': loading, + 'image-loader--amorphous': !this.hasSize(), + }); + + return ( + <div className={className}> + <canvas + className='image-loader__preview-canvas' + width={width} + height={height} + ref={this.setCanvasRef} + style={{ opacity: loading ? 1 : 0 }} + /> + + {!loading && ( + <img + alt={alt} + className='image-loader__img' + src={src} + width={width} + height={height} + /> + )} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/media_modal.js b/app/javascript/themes/glitch/features/ui/components/media_modal.js new file mode 100644 index 000000000..1dad972b2 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/media_modal.js @@ -0,0 +1,126 @@ +import React from 'react'; +import ReactSwipeableViews from 'react-swipeable-views'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ExtendedVideoPlayer from 'themes/glitch/components/extended_video_player'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'themes/glitch/components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImageLoader from './image_loader'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +@injectIntl +export default class MediaModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + index: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + index: null, + }; + + handleSwipe = (index) => { + this.setState({ index: index % this.props.media.size }); + } + + handleNextClick = () => { + this.setState({ index: (this.getIndex() + 1) % this.props.media.size }); + } + + handlePrevClick = () => { + 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': + this.handlePrevClick(); + break; + case 'ArrowRight': + this.handleNextClick(); + break; + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + getIndex () { + return this.state.index !== null ? this.state.index : this.props.index; + } + + render () { + 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} 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')} 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 containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}> + {content} + </ReactSwipeableViews> + </div> + <ul className='media-modal__pagination'> + {pagination} + </ul> + + {rightNav} + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/modal_loading.js b/app/javascript/themes/glitch/features/ui/components/modal_loading.js new file mode 100644 index 000000000..e14d20fbb --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/modal_loading.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import LoadingIndicator from 'themes/glitch/components/loading_indicator'; + +// Keep the markup in sync with <BundleModalError /> +// (make sure they have the same dimensions) +const ModalLoading = () => ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <LoadingIndicator /> + </div> + <div className='error-modal__footer'> + <div> + <button className='error-modal__nav onboarding-modal__skip' /> + </div> + </div> + </div> +); + +export default ModalLoading; diff --git a/app/javascript/themes/glitch/features/ui/components/modal_root.js b/app/javascript/themes/glitch/features/ui/components/modal_root.js new file mode 100644 index 000000000..fbe794170 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/modal_root.js @@ -0,0 +1,131 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import BundleContainer from '../containers/bundle_container'; +import BundleModalError from './bundle_modal_error'; +import ModalLoading from './modal_loading'; +import ActionsModal from './actions_modal'; +import MediaModal from './media_modal'; +import VideoModal from './video_modal'; +import BoostModal from './boost_modal'; +import DoodleModal from './doodle_modal'; +import ConfirmationModal from './confirmation_modal'; +import { + OnboardingModal, + MuteModal, + ReportModal, + SettingsModal, + EmbedModal, +} from 'themes/glitch/util/async-components'; + +const MODAL_COMPONENTS = { + 'MEDIA': () => Promise.resolve({ default: MediaModal }), + 'ONBOARDING': OnboardingModal, + 'VIDEO': () => Promise.resolve({ default: VideoModal }), + 'BOOST': () => Promise.resolve({ default: BoostModal }), + 'DOODLE': () => Promise.resolve({ default: DoodleModal }), + 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), + 'MUTE': MuteModal, + 'REPORT': ReportModal, + 'SETTINGS': SettingsModal, + 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), + 'EMBED': EmbedModal, +}; + +export default class ModalRoot extends React.PureComponent { + + static propTypes = { + type: PropTypes.string, + props: PropTypes.object, + onClose: PropTypes.func.isRequired, + }; + + state = { + revealed: false, + }; + + handleKeyUp = (e) => { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.type && !this.props.props.noEsc) { + this.props.onClose(); + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillReceiveProps (nextProps) { + if (!!nextProps.type && !this.props.type) { + this.activeElement = document.activeElement; + + this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + } else if (!nextProps.type) { + this.setState({ revealed: false }); + } + } + + componentDidUpdate (prevProps) { + if (!this.props.type && !!prevProps.type) { + this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + this.activeElement.focus(); + this.activeElement = null; + } + if (this.props.type) { + requestAnimationFrame(() => { + this.setState({ revealed: true }); + }); + } + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + getSiblings = () => { + return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node); + } + + setRef = ref => { + this.node = ref; + } + + renderLoading = modalId => () => { + return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; + } + + renderError = (props) => { + const { onClose } = this.props; + + return <BundleModalError {...props} onClose={onClose} />; + } + + render () { + const { type, props, onClose } = this.props; + const { revealed } = this.state; + const visible = !!type; + + if (!visible) { + return ( + <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> + ); + } + + return ( + <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> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/mute_modal.js b/app/javascript/themes/glitch/features/ui/components/mute_modal.js new file mode 100644 index 000000000..ffccdc84d --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/mute_modal.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Button from 'themes/glitch/components/button'; +import { closeModal } from 'themes/glitch/actions/modal'; +import { muteAccount } from 'themes/glitch/actions/accounts'; +import { toggleHideNotifications } from 'themes/glitch/actions/mutes'; + + +const mapStateToProps = state => { + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: state.getIn(['mutes', 'new', 'account']), + notifications: state.getIn(['mutes', 'new', 'notifications']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, + + onClose() { + dispatch(closeModal()); + }, + + onToggleNotifications() { + dispatch(toggleHideNotifications()); + }, + }; +}; + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class MuteModal extends React.PureComponent { + + static propTypes = { + isSubmitting: PropTypes.bool.isRequired, + account: PropTypes.object.isRequired, + notifications: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + onToggleNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account, this.props.notifications); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + toggleNotifications = () => { + this.props.onToggleNotifications(); + } + + render () { + const { account, notifications } = this.props; + + return ( + <div className='modal-root__modal mute-modal'> + <div className='mute-modal__container'> + <p> + <FormattedMessage + id='confirmations.mute.message' + defaultMessage='Are you sure you want to mute {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + <div> + <label htmlFor='mute-modal__hide-notifications-checkbox'> + <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> + {' '} + <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> + </label> + </div> + </div> + + <div className='mute-modal__action-bar'> + <Button onClick={this.handleCancel} className='mute-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleClick} ref={this.setRef}> + <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' /> + </Button> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js b/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js new file mode 100644 index 000000000..58875262e --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js @@ -0,0 +1,323 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import classNames from 'classnames'; +import Permalink from 'themes/glitch/components/permalink'; +import ComposeForm from 'themes/glitch/features/compose/components/compose_form'; +import Search from 'themes/glitch/features/compose/components/search'; +import NavigationBar from 'themes/glitch/features/compose/components/navigation_bar'; +import ColumnHeader from './column_header'; +import { + List as ImmutableList, + Map as ImmutableMap, +} from 'immutable'; +import { me } from 'themes/glitch/util/initial_state'; + +const noop = () => { }; + +const messages = defineMessages({ + home_title: { id: 'column.home', defaultMessage: 'Home' }, + notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + local_title: { id: 'column.community', defaultMessage: 'Local timeline' }, + federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }, +}); + +const PageOne = ({ acct, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-one'> + <div style={{ flex: '0 0 auto' }}> + <div className='onboarding-modal__page-one__elephant-friend' /> + </div> + + <div> + <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p> + <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p> + </div> + </div> +); + +PageOne.propTypes = { + acct: PropTypes.string.isRequired, + domain: PropTypes.string.isRequired, +}; + +const PageTwo = ({ myAccount }) => ( + <div className='onboarding-modal__page onboarding-modal__page-two'> + <div className='figure non-interactive'> + <div className='pseudo-drawer'> + <NavigationBar onClose={noop} account={myAccount} /> + </div> + <ComposeForm + text='Awoo! #introductions' + suggestions={ImmutableList()} + mentionedDomains={[]} + spoiler={false} + onChange={noop} + onSubmit={noop} + onPaste={noop} + onPickEmoji={noop} + onChangeSpoilerText={noop} + onClearSuggestions={noop} + onFetchSuggestions={noop} + onSuggestionSelected={noop} + onPrivacyChange={noop} + showSearch + settings={ImmutableMap.of('side_arm', 'none')} + /> + </div> + + <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p> + </div> +); + +PageTwo.propTypes = { + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageThree = ({ myAccount }) => ( + <div className='onboarding-modal__page onboarding-modal__page-three'> + <div className='figure non-interactive'> + <Search + value='' + onChange={noop} + onSubmit={noop} + onClear={noop} + onShow={noop} + /> + + <div className='pseudo-drawer'> + <NavigationBar onClose={noop} account={myAccount} /> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }} /></p> + <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p> + </div> +); + +PageThree.propTypes = { + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageFour = ({ domain, intl }) => ( + <div className='onboarding-modal__page onboarding-modal__page-four'> + <div className='onboarding-modal__page-four__columns'> + <div className='row'> + <div> + <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.' /></p> + </div> + + <div> + <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p> + </div> + </div> + + <div className='row'> + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div> + </div> + + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p> + </div> + </div> +); + +PageFour.propTypes = { + domain: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, +}; + +const PageSix = ({ admin, domain }) => { + let adminSection = ''; + + if (admin) { + adminSection = ( + <p> + <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} /> + <br /> + <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{ domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }} /> + </p> + ); + } + + return ( + <div className='onboarding-modal__page onboarding-modal__page-six'> + <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> + {adminSection} + <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ domain, fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>, github: <a href='https://github.com/glitch-soc/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> + <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p> + </div> + ); +}; + +PageSix.propTypes = { + admin: ImmutablePropTypes.map, + domain: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + myAccount: state.getIn(['accounts', me]), + admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), + domain: state.getIn(['meta', 'domain']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class OnboardingModal extends React.PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.isRequired, + admin: ImmutablePropTypes.map, + }; + + state = { + currentIndex: 0, + }; + + componentWillMount() { + const { myAccount, admin, domain, intl } = this.props; + this.pages = [ + <PageOne acct={myAccount.get('acct')} domain={domain} />, + <PageTwo myAccount={myAccount} />, + <PageThree myAccount={myAccount} />, + <PageFour domain={domain} intl={intl} />, + <PageSix admin={admin} domain={domain} />, + ]; + }; + + componentDidMount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + componentWillUnmount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + handleSkip = (e) => { + e.preventDefault(); + this.props.onClose(); + } + + handleDot = (e) => { + const i = Number(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.setState({ currentIndex: i }); + } + + handlePrev = () => { + this.setState(({ currentIndex }) => ({ + currentIndex: Math.max(0, currentIndex - 1), + })); + } + + handleNext = () => { + const { pages } = this; + this.setState(({ currentIndex }) => ({ + currentIndex: Math.min(currentIndex + 1, pages.length - 1), + })); + } + + handleSwipe = (index) => { + this.setState({ currentIndex: index }); + } + + handleKeyUp = ({ key }) => { + switch (key) { + case 'ArrowLeft': + this.handlePrev(); + break; + case 'ArrowRight': + this.handleNext(); + break; + } + } + + handleClose = () => { + this.props.onClose(); + } + + render () { + const { pages } = this; + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + const nextOrDoneBtn = hasMore ? ( + <button + onClick={this.handleNext} + className='onboarding-modal__nav onboarding-modal__next' + > + <FormattedMessage id='onboarding.next' defaultMessage='Next' /> + </button> + ) : ( + <button + onClick={this.handleClose} + className='onboarding-modal__nav onboarding-modal__done' + > + <FormattedMessage id='onboarding.done' defaultMessage='Done' /> + </button> + ); + + return ( + <div className='modal-root__modal onboarding-modal'> + <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='onboarding-modal__pager'> + {pages.map((page, i) => { + const className = classNames('onboarding-modal__page__wrapper', { + 'onboarding-modal__page__wrapper--active': i === currentIndex, + }); + return ( + <div key={i} className={className}>{page}</div> + ); + })} + </ReactSwipeableViews> + + <div className='onboarding-modal__paginator'> + <div> + <button + onClick={this.handleSkip} + className='onboarding-modal__nav onboarding-modal__skip' + > + <FormattedMessage id='onboarding.skip' defaultMessage='Skip' /> + </button> + </div> + + <div className='onboarding-modal__dots'> + {pages.map((_, i) => { + const className = classNames('onboarding-modal__dot', { + active: i === currentIndex, + }); + return ( + <div + key={`dot-${i}`} + role='button' + tabIndex='0' + data-index={i} + onClick={this.handleDot} + className={className} + /> + ); + })} + </div> + + <div> + {nextOrDoneBtn} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/report_modal.js b/app/javascript/themes/glitch/features/ui/components/report_modal.js new file mode 100644 index 000000000..e6153948e --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/report_modal.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { changeReportComment, submitReport } from 'themes/glitch/actions/reports'; +import { refreshAccountTimeline } from 'themes/glitch/actions/timelines'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { makeGetAccount } from 'themes/glitch/selectors'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import StatusCheckBox from 'themes/glitch/features/report/containers/status_check_box_container'; +import { OrderedSet } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Button from 'themes/glitch/components/button'; + +const messages = defineMessages({ + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, + submit: { id: 'report.submit', defaultMessage: 'Submit' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => { + const accountId = state.getIn(['reports', 'new', 'account_id']); + + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: getAccount(state, accountId), + comment: state.getIn(['reports', 'new', 'comment']), + statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), + }; + }; + + return mapStateToProps; +}; + +@connect(makeMapStateToProps) +@injectIntl +export default class ReportModal extends ImmutablePureComponent { + + static propTypes = { + isSubmitting: PropTypes.bool, + account: ImmutablePropTypes.map, + statusIds: ImmutablePropTypes.orderedSet.isRequired, + comment: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleCommentChange = (e) => { + this.props.dispatch(changeReportComment(e.target.value)); + } + + handleSubmit = () => { + this.props.dispatch(submitReport()); + } + + componentDidMount () { + this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'))); + } + + componentWillReceiveProps (nextProps) { + if (this.props.account !== nextProps.account && nextProps.account) { + this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id'))); + } + } + + render () { + const { account, comment, intl, statusIds, isSubmitting } = this.props; + + if (!account) { + return null; + } + + return ( + <div className='modal-root__modal report-modal'> + <div className='report-modal__target'> + <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} /> + </div> + + <div className='report-modal__container'> + <div className='report-modal__statuses'> + <div> + {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} + </div> + </div> + + <div className='report-modal__comment'> + <textarea + className='setting-text light' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleCommentChange} + disabled={isSubmitting} + /> + </div> + </div> + + <div className='report-modal__action-bar'> + <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/tabs_bar.js b/app/javascript/themes/glitch/features/ui/components/tabs_bar.js new file mode 100644 index 000000000..ef5deae99 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/tabs_bar.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { NavLink } from 'react-router-dom'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { debounce } from 'lodash'; +import { isUserTouching } from 'themes/glitch/util/is_mobile'; + +export const links = [ + <NavLink className='tabs-bar__link primary' to='/statuses/new' data-preview-title-id='tabs_bar.compose' data-preview-icon='pencil' ><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></NavLink>, + <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, + <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, + + <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, + <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, + + <NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='asterisk' ><i className='fa fa-fw fa-asterisk' /></NavLink>, +]; + +export function getIndex (path) { + return links.findIndex(link => link.props.to === path); +} + +export function getLink (index) { + return links[index].props.to; +} + +@injectIntl +export default class TabsBar extends React.Component { + + static contextTypes = { + router: PropTypes.object.isRequired, + } + + static propTypes = { + intl: PropTypes.object.isRequired, + } + + setRef = ref => { + this.node = ref; + } + + handleClick = (e) => { + // Only apply optimization for touch devices, which we assume are slower + // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices + if (isUserTouching()) { + e.preventDefault(); + e.persist(); + + requestAnimationFrame(() => { + const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link')); + const currentTab = tabs.find(tab => tab.classList.contains('active')); + const nextTab = tabs.find(tab => tab.contains(e.target)); + const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)]; + + + if (currentTab !== nextTab) { + if (currentTab) { + currentTab.classList.remove('active'); + } + + const listener = debounce(() => { + nextTab.removeEventListener('transitionend', listener); + this.context.router.history.push(to); + }, 50); + + nextTab.addEventListener('transitionend', listener); + nextTab.classList.add('active'); + } + }); + } + + } + + render () { + const { intl: { formatMessage } } = this.props; + + return ( + <nav className='tabs-bar' ref={this.setRef}> + {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))} + </nav> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/upload_area.js b/app/javascript/themes/glitch/features/ui/components/upload_area.js new file mode 100644 index 000000000..72a450215 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/upload_area.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'themes/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { FormattedMessage } from 'react-intl'; + +export default class UploadArea extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + onClose: PropTypes.func, + }; + + handleKeyUp = (e) => { + const keyCode = e.keyCode; + if (this.props.active) { + switch(keyCode) { + case 27: + e.preventDefault(); + e.stopPropagation(); + this.props.onClose(); + break; + } + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + render () { + const { active } = this.props; + + return ( + <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}> + {({ backgroundOpacity, backgroundScale }) => + <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> + <div className='upload-area__drop'> + <div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div> + } + </Motion> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/components/video_modal.js b/app/javascript/themes/glitch/features/ui/components/video_modal.js new file mode 100644 index 000000000..91168c790 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/components/video_modal.js @@ -0,0 +1,33 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Video from 'themes/glitch/features/video'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class VideoModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + time: PropTypes.number, + onClose: PropTypes.func.isRequired, + }; + + render () { + const { media, time, onClose } = this.props; + + return ( + <div className='modal-root__modal media-modal'> + <div> + <Video + preview={media.get('preview_url')} + src={media.get('url')} + startTime={time} + onCloseVideo={onClose} + description={media.get('description')} + /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/features/ui/containers/bundle_container.js b/app/javascript/themes/glitch/features/ui/containers/bundle_container.js new file mode 100644 index 000000000..e6f9afcf7 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/containers/bundle_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; + +import Bundle from '../components/bundle'; + +import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from 'themes/glitch/actions/bundles'; + +const mapDispatchToProps = dispatch => ({ + onFetch () { + dispatch(fetchBundleRequest()); + }, + onFetchSuccess () { + dispatch(fetchBundleSuccess()); + }, + onFetchFail (error) { + dispatch(fetchBundleFail(error)); + }, +}); + +export default connect(null, mapDispatchToProps)(Bundle); diff --git a/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js b/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js new file mode 100644 index 000000000..95f95618b --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import ColumnsArea from '../components/columns_area'; + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), +}); + +export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea); diff --git a/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js b/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js new file mode 100644 index 000000000..4bb90fb68 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import LoadingBar from 'react-redux-loading-bar'; + +const mapStateToProps = (state) => ({ + loading: state.get('loadingBar'), +}); + +export default connect(mapStateToProps)(LoadingBar.WrappedComponent); diff --git a/app/javascript/themes/glitch/features/ui/containers/modal_container.js b/app/javascript/themes/glitch/features/ui/containers/modal_container.js new file mode 100644 index 000000000..c26f19886 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/containers/modal_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { closeModal } from 'themes/glitch/actions/modal'; +import ModalRoot from '../components/modal_root'; + +const mapStateToProps = state => ({ + type: state.get('modal').modalType, + props: state.get('modal').modalProps, +}); + +const mapDispatchToProps = dispatch => ({ + onClose () { + dispatch(closeModal()); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/javascript/themes/glitch/features/ui/containers/notifications_container.js b/app/javascript/themes/glitch/features/ui/containers/notifications_container.js new file mode 100644 index 000000000..5bd4017f5 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/containers/notifications_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import { NotificationStack } from 'react-notification'; +import { dismissAlert } from 'themes/glitch/actions/alerts'; +import { getAlerts } from 'themes/glitch/selectors'; + +const mapStateToProps = state => ({ + notifications: getAlerts(state), +}); + +const mapDispatchToProps = (dispatch) => { + return { + onDismiss: alert => { + dispatch(dismissAlert(alert)); + }, + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack); diff --git a/app/javascript/themes/glitch/features/ui/containers/status_list_container.js b/app/javascript/themes/glitch/features/ui/containers/status_list_container.js new file mode 100644 index 000000000..807c82e16 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/containers/status_list_container.js @@ -0,0 +1,73 @@ +import { connect } from 'react-redux'; +import StatusList from 'themes/glitch/components/status_list'; +import { scrollTopTimeline } from 'themes/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { createSelector } from 'reselect'; +import { debounce } from 'lodash'; +import { me } from 'themes/glitch/util/initial_state'; + +const makeGetStatusIds = () => createSelector([ + (state, { type }) => state.getIn(['settings', type], ImmutableMap()), + (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), + (state) => state.get('statuses'), +], (columnSettings, statusIds, statuses) => { + const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); + let regex = null; + + try { + regex = rawRegex && new RegExp(rawRegex, 'i'); + } catch (e) { + // Bad regex, don't affect filters + } + + return statusIds.filter(id => { + const statusForId = statuses.get(id); + let showStatus = true; + + if (columnSettings.getIn(['shows', 'reblog']) === false) { + showStatus = showStatus && statusForId.get('reblog') === null; + } + + if (columnSettings.getIn(['shows', 'reply']) === false) { + showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); + } + + if (showStatus && regex && statusForId.get('account') !== me) { + const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index'); + showStatus = !regex.test(searchIndex); + } + + return showStatus; + }); +}); + +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + + const mapStateToProps = (state, { timelineId }) => ({ + statusIds: getStatusIds(state, { type: timelineId }), + isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), + hasMore: !!state.getIn(['timelines', timelineId, 'next']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({ + + onScrollToBottom: debounce(() => { + dispatch(scrollTopTimeline(timelineId, false)); + loadMore(); + }, 300, { leading: true }), + + onScrollToTop: debounce(() => { + dispatch(scrollTopTimeline(timelineId, true)); + }, 100), + + onScroll: debounce(() => { + dispatch(scrollTopTimeline(timelineId, false)); + }, 100), + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/themes/glitch/features/ui/index.js b/app/javascript/themes/glitch/features/ui/index.js new file mode 100644 index 000000000..b59a2e637 --- /dev/null +++ b/app/javascript/themes/glitch/features/ui/index.js @@ -0,0 +1,442 @@ +import React from 'react'; +import NotificationsContainer from './containers/notifications_container'; +import PropTypes from 'prop-types'; +import LoadingBarContainer from './containers/loading_bar_container'; +import TabsBar from './components/tabs_bar'; +import ModalContainer from './containers/modal_container'; +import { connect } from 'react-redux'; +import { Redirect, withRouter } from 'react-router-dom'; +import { isMobile } from 'themes/glitch/util/is_mobile'; +import { debounce } from 'lodash'; +import { uploadCompose, resetCompose } from 'themes/glitch/actions/compose'; +import { refreshHomeTimeline } from 'themes/glitch/actions/timelines'; +import { refreshNotifications } from 'themes/glitch/actions/notifications'; +import { clearHeight } from 'themes/glitch/actions/height_cache'; +import { WrappedSwitch, WrappedRoute } from 'themes/glitch/util/react_router_helpers'; +import UploadArea from './components/upload_area'; +import ColumnsAreaContainer from './containers/columns_area_container'; +import classNames from 'classnames'; +import { + Compose, + Status, + GettingStarted, + PublicTimeline, + CommunityTimeline, + AccountTimeline, + AccountGallery, + HomeTimeline, + Followers, + Following, + Reblogs, + Favourites, + DirectTimeline, + HashtagTimeline, + Notifications, + FollowRequests, + GenericNotFound, + FavouritedStatuses, + Blocks, + Mutes, + PinnedStatuses, +} from 'themes/glitch/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import { me } from 'themes/glitch/util/initial_state'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Dummy import, to make sure that <Status /> ends up in the application bundle. +// Without this it ends up in ~8 very commonly used bundles. +import '../../../glitch/components/status'; + +const messages = defineMessages({ + beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, +}); + +const mapStateToProps = state => ({ + isComposing: state.getIn(['compose', 'is_composing']), + hasComposingText: state.getIn(['compose', 'text']) !== '', + layout: state.getIn(['local_settings', 'layout']), + isWide: state.getIn(['local_settings', 'stretch']), + navbarUnder: state.getIn(['local_settings', 'navbar_under']), +}); + +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', + goToDirect: 'g d', + goToStart: 'g s', + goToFavourites: 'g f', + goToPinned: 'g p', + goToProfile: 'g u', + goToBlocked: 'g b', + goToMuted: 'g m', +}; + +@connect(mapStateToProps) +@injectIntl +@withRouter +export default class UI extends React.Component { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + children: PropTypes.node, + layout: PropTypes.string, + isWide: PropTypes.bool, + systemFontUi: PropTypes.bool, + navbarUnder: PropTypes.bool, + isComposing: PropTypes.bool, + hasComposingText: PropTypes.bool, + location: PropTypes.object, + intl: PropTypes.object.isRequired, + }; + + state = { + width: window.innerWidth, + draggingOver: false, + }; + + handleBeforeUnload = (e) => { + const { intl, isComposing, hasComposingText } = this.props; + + if (isComposing && hasComposingText) { + // Setting returnValue to any string causes confirmation dialog. + // Many browsers no longer display this text to users, + // but we set user-friendly message for other browsers, e.g. Edge. + e.returnValue = intl.formatMessage(messages.beforeUnload); + } + } + + handleResize = debounce(() => { + // The cached heights are no longer accurate, invalidate + this.props.dispatch(clearHeight()); + + this.setState({ width: window.innerWidth }); + }, 500, { + trailing: true, + }); + + handleDragEnter = (e) => { + e.preventDefault(); + + if (!this.dragTargets) { + this.dragTargets = []; + } + + if (this.dragTargets.indexOf(e.target) === -1) { + this.dragTargets.push(e.target); + } + + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + this.setState({ draggingOver: true }); + } + } + + handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + + try { + e.dataTransfer.dropEffect = 'copy'; + } catch (err) { + + } + + return false; + } + + handleDrop = (e) => { + e.preventDefault(); + + this.setState({ draggingOver: false }); + + if (e.dataTransfer && e.dataTransfer.files.length === 1) { + this.props.dispatch(uploadCompose(e.dataTransfer.files)); + } + } + + handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); + + if (this.dragTargets.length > 0) { + return; + } + + this.setState({ draggingOver: false }); + } + + closeUploadModal = () => { + this.setState({ draggingOver: false }); + } + + handleServiceWorkerPostMessage = ({ data }) => { + if (data.type === 'navigate') { + this.context.router.history.push(data.path); + } else { + console.warn('Unknown message type:', data.type); + } + } + + componentWillMount () { + window.addEventListener('beforeunload', this.handleBeforeUnload, false); + window.addEventListener('resize', this.handleResize, { passive: true }); + document.addEventListener('dragenter', this.handleDragEnter, false); + document.addEventListener('dragover', this.handleDragOver, false); + document.addEventListener('drop', this.handleDrop, false); + document.addEventListener('dragleave', this.handleDragLeave, false); + document.addEventListener('dragend', this.handleDragEnd, false); + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); + } + + this.props.dispatch(refreshHomeTimeline()); + 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 + this.node.classList.toggle('is-composing', nextProps.isComposing); + this.node.classList.toggle('navbar-under', nextProps.navbarUnder); + + return false; + } + + // Why isn't this working?!? + // return super.shouldComponentUpdate(nextProps, nextState); + return true; + } + + componentDidUpdate (prevProps) { + if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { + this.columnsAreaNode.handleChildrenContentChange(); + } + } + + componentWillUnmount () { + window.removeEventListener('beforeunload', this.handleBeforeUnload); + window.removeEventListener('resize', this.handleResize); + document.removeEventListener('dragenter', this.handleDragEnter); + document.removeEventListener('dragover', this.handleDragOver); + document.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragleave', this.handleDragLeave); + document.removeEventListener('dragend', this.handleDragEnd); + } + + setRef = c => { + this.node = c; + } + + setColumnsAreaRef = c => { + this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); + } + + 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'); + } + + handleHotkeyGoToDirect = () => { + this.context.router.history.push('/timelines/direct'); + } + + 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/${me}`); + } + + handleHotkeyGoToBlocked = () => { + this.context.router.history.push('/blocks'); + } + + handleHotkeyGoToMuted = () => { + this.context.router.history.push('/mutes'); + } + + render () { + const { width, draggingOver } = this.state; + const { children, layout, isWide, navbarUnder } = this.props; + + const columnsClass = layout => { + switch (layout) { + case 'single': + return 'single-column'; + case 'multiple': + return 'multi-columns'; + default: + return 'auto-columns'; + } + }; + + const className = classNames('ui', columnsClass(layout), { + 'wide': isWide, + 'system-font': this.props.systemFontUi, + '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, + goToDirect: this.handleHotkeyGoToDirect, + goToStart: this.handleHotkeyGoToStart, + goToFavourites: this.handleHotkeyGoToFavourites, + goToPinned: this.handleHotkeyGoToPinned, + goToProfile: this.handleHotkeyGoToProfile, + goToBlocked: this.handleHotkeyGoToBlocked, + goToMuted: this.handleHotkeyGoToMuted, + }; + + return ( + <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/direct' component={DirectTimeline} 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/themes/glitch/features/video/index.js b/app/javascript/themes/glitch/features/video/index.js new file mode 100644 index 000000000..0ecbe37c9 --- /dev/null +++ b/app/javascript/themes/glitch/features/video/index.js @@ -0,0 +1,288 @@ +import React from 'react'; +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 'themes/glitch/util/fullscreen'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, + hide: { id: 'video.hide', defaultMessage: 'Hide video' }, + expand: { id: 'video.expand', defaultMessage: 'Expand video' }, + close: { id: 'video.close', defaultMessage: 'Close video' }, + fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, + exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, +}); + +const findElementPosition = el => { + let box; + + if (el.getBoundingClientRect && el.parentNode) { + box = el.getBoundingClientRect(); + } + + if (!box) { + return { + left: 0, + top: 0, + }; + } + + const docEl = document.documentElement; + const body = document.body; + + const clientLeft = docEl.clientLeft || body.clientLeft || 0; + const scrollLeft = window.pageXOffset || body.scrollLeft; + const left = (box.left + scrollLeft) - clientLeft; + + const clientTop = docEl.clientTop || body.clientTop || 0; + const scrollTop = window.pageYOffset || body.scrollTop; + const top = (box.top + scrollTop) - clientTop; + + return { + left: Math.round(left), + top: Math.round(top), + }; +}; + +const getPointerPosition = (el, event) => { + const position = {}; + const box = findElementPosition(el); + const boxW = el.offsetWidth; + const boxH = el.offsetHeight; + const boxY = box.top; + const boxX = box.left; + + let pageY = event.pageY; + let pageX = event.pageX; + + if (event.changedTouches) { + pageX = event.changedTouches[0].pageX; + pageY = event.changedTouches[0].pageY; + } + + position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH)); + position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); + + return position; +}; + +@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, + startTime: PropTypes.number, + onOpenVideo: PropTypes.func, + onCloseVideo: PropTypes.func, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + progress: 0, + paused: true, + dragging: false, + fullscreen: false, + hovered: false, + muted: false, + revealed: !this.props.sensitive, + }; + + setPlayerRef = c => { + this.player = c; + } + + setVideoRef = c => { + this.video = c; + } + + setSeekRef = c => { + this.seek = c; + } + + handlePlay = () => { + this.setState({ paused: false }); + } + + handlePause = () => { + this.setState({ paused: true }); + } + + handleTimeUpdate = () => { + this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) }); + } + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove, true); + document.addEventListener('mouseup', this.handleMouseUp, true); + document.addEventListener('touchmove', this.handleMouseMove, true); + document.addEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: true }); + this.video.pause(); + this.handleMouseMove(e); + } + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove, true); + document.removeEventListener('mouseup', this.handleMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseMove, true); + document.removeEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: false }); + this.video.play(); + } + + handleMouseMove = throttle(e => { + const { x } = getPointerPosition(this.seek, e); + this.video.currentTime = this.video.duration * x; + this.setState({ progress: x * 100 }); + }, 60); + + togglePlay = () => { + if (this.state.paused) { + this.video.play(); + } else { + this.video.pause(); + } + } + + toggleFullscreen = () => { + if (isFullscreen()) { + exitFullscreen(); + } else { + requestFullscreen(this.player); + } + } + + componentDidMount () { + document.addEventListener('fullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + } + + componentWillUnmount () { + document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + } + + handleFullscreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + toggleMute = () => { + this.video.muted = !this.video.muted; + this.setState({ muted: this.video.muted }); + } + + toggleReveal = () => { + if (this.state.revealed) { + this.video.pause(); + } + + this.setState({ revealed: !this.state.revealed }); + } + + handleLoadedData = () => { + if (this.props.startTime) { + this.video.currentTime = this.props.startTime; + this.video.play(); + } + } + + 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); + } + + handleCloseVideo = () => { + this.video.pause(); + this.props.onCloseVideo(); + } + + render () { + const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth } = this.props; + const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; + + return ( + <div className={classNames('video-player', { inactive: !revealed, inline: !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <video + ref={this.setVideoRef} + src={src} + poster={preview} + preload={startTime ? 'auto' : 'none'} + loop + role='button' + tabIndex='0' + aria-label={alt} + width={width} + height={height} + onClick={this.togglePlay} + onPlay={this.handlePlay} + onPause={this.handlePause} + onTimeUpdate={this.handleTimeUpdate} + onLoadedData={this.handleLoadedData} + onProgress={this.handleProgress} + /> + + <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> + <span className='video-player__spoiler__title'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </button> + + <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 + className={classNames('video-player__seek__handle', { active: dragging })} + tabIndex='0' + style={{ left: `${progress}%` }} + /> + </div> + + <div className='video-player__buttons left'> + <button aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button> + <button aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button> + {!onCloseVideo && <button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>} + </div> + + <div className='video-player__buttons right'> + {(!fullscreen && onOpenVideo) && <button aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>} + {onCloseVideo && <button aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-times' /></button>} + <button aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/themes/glitch/middleware/errors.js b/app/javascript/themes/glitch/middleware/errors.js new file mode 100644 index 000000000..c54e7b0a2 --- /dev/null +++ b/app/javascript/themes/glitch/middleware/errors.js @@ -0,0 +1,31 @@ +import { showAlert } from 'themes/glitch/actions/alerts'; + +const defaultFailSuffix = 'FAIL'; + +export default function errorsMiddleware() { + return ({ dispatch }) => next => action => { + if (action.type && !action.skipAlert) { + const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); + + if (action.type.match(isFail)) { + if (action.error.response) { + const { data, status, statusText } = action.error.response; + + let message = statusText; + let title = `${status}`; + + if (data.error) { + message = data.error; + } + + dispatch(showAlert(title, message)); + } else { + console.error(action.error); + dispatch(showAlert('Oops!', 'An unexpected error occurred.')); + } + } + } + + return next(action); + }; +}; diff --git a/app/javascript/themes/glitch/middleware/loading_bar.js b/app/javascript/themes/glitch/middleware/loading_bar.js new file mode 100644 index 000000000..a98f1bb2b --- /dev/null +++ b/app/javascript/themes/glitch/middleware/loading_bar.js @@ -0,0 +1,25 @@ +import { showLoading, hideLoading } from 'react-redux-loading-bar'; + +const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED']; + +export default function loadingBarMiddleware(config = {}) { + const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes; + + return ({ dispatch }) => next => (action) => { + if (action.type && !action.skipLoading) { + const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes; + + const isPending = new RegExp(`${PENDING}$`, 'g'); + const isFulfilled = new RegExp(`${FULFILLED}$`, 'g'); + const isRejected = new RegExp(`${REJECTED}$`, 'g'); + + if (action.type.match(isPending)) { + dispatch(showLoading()); + } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) { + dispatch(hideLoading()); + } + } + + return next(action); + }; +}; diff --git a/app/javascript/themes/glitch/middleware/sounds.js b/app/javascript/themes/glitch/middleware/sounds.js new file mode 100644 index 000000000..3d1e3eaba --- /dev/null +++ b/app/javascript/themes/glitch/middleware/sounds.js @@ -0,0 +1,46 @@ +const createAudio = sources => { + const audio = new Audio(); + sources.forEach(({ type, src }) => { + const source = document.createElement('source'); + source.type = type; + source.src = src; + audio.appendChild(source); + }); + return audio; +}; + +const play = audio => { + if (!audio.paused) { + audio.pause(); + if (typeof audio.fastSeek === 'function') { + audio.fastSeek(0); + } else { + audio.seek(0); + } + } + + audio.play(); +}; + +export default function soundsMiddleware() { + const soundCache = { + boop: createAudio([ + { + src: '/sounds/boop.ogg', + type: 'audio/ogg', + }, + { + src: '/sounds/boop.mp3', + type: 'audio/mpeg', + }, + ]), + }; + + return () => next => action => { + if (action.meta && action.meta.sound && soundCache[action.meta.sound]) { + play(soundCache[action.meta.sound]); + } + + return next(action); + }; +}; diff --git a/app/javascript/packs/about.js b/app/javascript/themes/glitch/packs/about.js index 50c81198e..9639d5453 100644 --- a/app/javascript/packs/about.js +++ b/app/javascript/themes/glitch/packs/about.js @@ -1,9 +1,7 @@ -import loadPolyfills from '../mastodon/load_polyfills'; - -require.context('../images/', true); +import loadPolyfills from 'themes/glitch/util/load_polyfills'; function loaded() { - const TimelineContainer = require('../mastodon/containers/timeline_container').default; + const TimelineContainer = require('themes/glitch/containers/timeline_container').default; const React = require('react'); const ReactDOM = require('react-dom'); const mountNode = document.getElementById('mastodon-timeline'); @@ -15,7 +13,7 @@ function loaded() { } function main() { - const ready = require('../mastodon/ready').default; + const ready = require('themes/glitch/util/ready').default; ready(loaded); } diff --git a/app/javascript/themes/glitch/packs/common.js b/app/javascript/themes/glitch/packs/common.js new file mode 100644 index 000000000..3a62700bd --- /dev/null +++ b/app/javascript/themes/glitch/packs/common.js @@ -0,0 +1,3 @@ +import 'font-awesome/css/font-awesome.css'; +require.context('../../images/', true); +import './styles/index.scss'; diff --git a/app/javascript/themes/glitch/packs/home.js b/app/javascript/themes/glitch/packs/home.js new file mode 100644 index 000000000..dada28317 --- /dev/null +++ b/app/javascript/themes/glitch/packs/home.js @@ -0,0 +1,7 @@ +import loadPolyfills from './util/load_polyfills'; + +loadPolyfills().then(() => { + require('./util/main').default(); +}).catch(e => { + console.error(e); +}); diff --git a/app/javascript/packs/public.js b/app/javascript/themes/glitch/packs/public.js index a47fc2830..6adacad98 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/themes/glitch/packs/public.js @@ -1,5 +1,6 @@ -import loadPolyfills from '../mastodon/load_polyfills'; -import ready from '../mastodon/ready'; +import loadPolyfills from 'themes/glitch/util/load_polyfills'; +import { processBio } from 'themes/glitch/util/bio_metadata'; +import ready from 'themes/glitch/util/ready'; window.addEventListener('message', e => { const data = e.data || {}; @@ -21,12 +22,12 @@ function main() { const { length } = require('stringz'); const IntlRelativeFormat = require('intl-relativeformat').default; const { delegate } = require('rails-ujs'); - const emojify = require('../mastodon/features/emoji/emoji').default; - const { getLocale } = require('../mastodon/locales'); + const emojify = require('../themes/glitch/util/emoji').default; + const { getLocale } = require('mastodon/locales'); const { localeData } = getLocale(); - const VideoContainer = require('../mastodon/containers/video_container').default; - const MediaGalleryContainer = require('../mastodon/containers/media_gallery_container').default; - const CardContainer = require('../mastodon/containers/card_container').default; + const VideoContainer = require('../themes/glitch/containers/video_container').default; + const MediaGalleryContainer = require('../themes/glitch/containers/media_gallery_container').default; + const CardContainer = require('../themes/glitch/containers/card_container').default; const React = require('react'); const ReactDOM = require('react-dom'); @@ -121,7 +122,8 @@ function main() { const noteCounter = document.querySelector('.note-counter'); if (noteCounter) { - noteCounter.textContent = 160 - length(target.value); + const noteWithoutMetadata = processBio(target.value).text; + noteCounter.textContent = 500 - length(noteWithoutMetadata); } }); diff --git a/app/javascript/packs/share.js b/app/javascript/themes/glitch/packs/share.js index 51e4ae38b..dc2e677e4 100644 --- a/app/javascript/packs/share.js +++ b/app/javascript/themes/glitch/packs/share.js @@ -1,9 +1,7 @@ -import loadPolyfills from '../mastodon/load_polyfills'; - -require.context('../images/', true); +import loadPolyfills from 'themes/glitch/util/load_polyfills'; function loaded() { - const ComposeContainer = require('../mastodon/containers/compose_container').default; + const ComposeContainer = require('themes/glitch/containers/compose_container').default; const React = require('react'); const ReactDOM = require('react-dom'); const mountNode = document.getElementById('mastodon-compose'); @@ -15,7 +13,7 @@ function loaded() { } function main() { - const ready = require('../mastodon/ready').default; + const ready = require('themes/glitch/util/ready').default; ready(loaded); } diff --git a/app/javascript/themes/glitch/reducers/accounts.js b/app/javascript/themes/glitch/reducers/accounts.js new file mode 100644 index 000000000..0a65d3723 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/accounts.js @@ -0,0 +1,135 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, +} from 'themes/glitch/actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS, +} from 'themes/glitch/actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS, +} from 'themes/glitch/actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from 'themes/glitch/actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS, +} from 'themes/glitch/actions/interactions'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS, +} from 'themes/glitch/actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS, +} from 'themes/glitch/actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from 'themes/glitch/actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS, +} from 'themes/glitch/actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS, +} from 'themes/glitch/actions/favourites'; +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import emojify from 'themes/glitch/util/emoji'; +import { Map as ImmutableMap, fromJS } from 'immutable'; +import escapeTextContentForBrowser from 'escape-html'; + +const normalizeAccount = (state, account) => { + account = { ...account }; + + delete account.followers_count; + delete account.following_count; + delete account.statuses_count; + + const displayName = account.display_name.length === 0 ? account.username : account.display_name; + account.display_name_html = emojify(escapeTextContentForBrowser(displayName)); + account.note_emojified = emojify(account.note); + + return state.set(account.id, fromJS(account)); +}; + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +const normalizeAccountFromStatus = (state, status) => { + state = normalizeAccount(state, status.account); + + if (status.reblog && status.reblog.account) { + state = normalizeAccount(state, status.reblog.account); + } + + return state; +}; + +const normalizeAccountsFromStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeAccountFromStatus(state, status); + }); + + return state; +}; + +const initialState = ImmutableMap(); + +export default function accounts(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('accounts')); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + case BLOCKS_FETCH_SUCCESS: + case BLOCKS_EXPAND_SUCCESS: + case MUTES_FETCH_SUCCESS: + case MUTES_EXPAND_SUCCESS: + return action.accounts ? normalizeAccounts(state, action.accounts) : state; + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/accounts_counters.js b/app/javascript/themes/glitch/reducers/accounts_counters.js new file mode 100644 index 000000000..e3728ecd7 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/accounts_counters.js @@ -0,0 +1,138 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, +} from 'themes/glitch/actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS, +} from 'themes/glitch/actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS, +} from 'themes/glitch/actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from 'themes/glitch/actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS, +} from 'themes/glitch/actions/interactions'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS, +} from 'themes/glitch/actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS, +} from 'themes/glitch/actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from 'themes/glitch/actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS, +} from 'themes/glitch/actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS, +} from 'themes/glitch/actions/favourites'; +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const normalizeAccount = (state, account) => state.set(account.id, fromJS({ + followers_count: account.followers_count, + following_count: account.following_count, + statuses_count: account.statuses_count, +})); + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +const normalizeAccountFromStatus = (state, status) => { + state = normalizeAccount(state, status.account); + + if (status.reblog && status.reblog.account) { + state = normalizeAccount(state, status.reblog.account); + } + + return state; +}; + +const normalizeAccountsFromStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeAccountFromStatus(state, status); + }); + + return state; +}; + +const initialState = ImmutableMap(); + +export default function accountsCounters(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('accounts').map(item => fromJS({ + followers_count: item.get('followers_count'), + following_count: item.get('following_count'), + statuses_count: item.get('statuses_count'), + }))); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + case BLOCKS_FETCH_SUCCESS: + case BLOCKS_EXPAND_SUCCESS: + case MUTES_FETCH_SUCCESS: + case MUTES_EXPAND_SUCCESS: + return action.accounts ? normalizeAccounts(state, action.accounts) : state; + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + case ACCOUNT_FOLLOW_SUCCESS: + if (action.alreadyFollowing) { + return state; + } + return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); + case ACCOUNT_UNFOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/alerts.js b/app/javascript/themes/glitch/reducers/alerts.js new file mode 100644 index 000000000..ad66b63f6 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/alerts.js @@ -0,0 +1,25 @@ +import { + ALERT_SHOW, + ALERT_DISMISS, + ALERT_CLEAR, +} from 'themes/glitch/actions/alerts'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableList([]); + +export default function alerts(state = initialState, action) { + switch(action.type) { + case ALERT_SHOW: + return state.push(ImmutableMap({ + key: state.size > 0 ? state.last().get('key') + 1 : 0, + title: action.title, + message: action.message, + })); + case ALERT_DISMISS: + return state.filterNot(item => item.get('key') === action.alert.key); + case ALERT_CLEAR: + return state.clear(); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/cards.js b/app/javascript/themes/glitch/reducers/cards.js new file mode 100644 index 000000000..35be30444 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/cards.js @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from 'themes/glitch/actions/cards'; + +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/compose.js b/app/javascript/themes/glitch/reducers/compose.js new file mode 100644 index 000000000..be359fcb4 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/compose.js @@ -0,0 +1,307 @@ +import { + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_CHANGE, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_MENTION, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_UNDO, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_ADVANCED_OPTIONS_CHANGE, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_COMPOSING_CHANGE, + COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_DOODLE_SET, + COMPOSE_RESET, +} from 'themes/glitch/actions/compose'; +import { TIMELINE_DELETE } from 'themes/glitch/actions/timelines'; +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; +import uuid from 'themes/glitch/util/uuid'; +import { me } from 'themes/glitch/util/initial_state'; + +const initialState = ImmutableMap({ + mounted: false, + advanced_options: ImmutableMap({ + do_not_federate: false, + }), + sensitive: false, + spoiler: false, + spoiler_text: '', + privacy: null, + text: '', + focusDate: null, + preselectDate: null, + in_reply_to: null, + is_composing: false, + is_submitting: false, + is_uploading: false, + progress: 0, + media_attachments: ImmutableList(), + suggestion_token: null, + suggestions: ImmutableList(), + default_advanced_options: ImmutableMap({ + do_not_federate: false, + }), + default_privacy: 'public', + default_sensitive: false, + resetFileKey: Math.floor((Math.random() * 0x10000)), + idempotencyKey: null, + doodle: ImmutableMap({ + fg: 'rgb( 0, 0, 0)', + bg: 'rgb(255, 255, 255)', + swapped: false, + mode: 'draw', + size: 'normal', + weight: 2, + opacity: 1, + adaptiveStroke: true, + smoothing: false, + }), +}); + +function statusToTextMentions(state, status) { + let set = ImmutableOrderedSet([]); + + if (status.getIn(['account', 'id']) !== me) { + set = set.add(`@${status.getIn(['account', 'acct'])} `); + } + + return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); +}; + +function clearAll(state) { + return state.withMutations(map => { + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('is_submitting', false); + map.set('in_reply_to', null); + map.set('advanced_options', state.get('default_advanced_options')); + map.set('privacy', state.get('default_privacy')); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('idempotencyKey', uuid()); + }); +}; + +function appendMedia(state, media) { + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + map.update('media_attachments', list => list.push(media)); + map.set('is_uploading', false); + map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); + map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + + if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) { + map.set('sensitive', true); + } + }); +}; + +function removeMedia(state, mediaId) { + const media = state.get('media_attachments').find(item => item.get('id') === mediaId); + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId)); + map.update('text', text => text.replace(media.get('text_url'), '').trim()); + map.set('idempotencyKey', uuid()); + + if (prevSize === 1) { + map.set('sensitive', false); + } + }); +}; + +const insertSuggestion = (state, position, token, completion) => { + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`); + map.set('suggestion_token', null); + map.update('suggestions', ImmutableList(), list => list.clear()); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +const insertEmoji = (state, position, emojiData) => { + const emoji = emojiData.native; + + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +const privacyPreference = (a, b) => { + if (a === 'direct' || b === 'direct') { + return 'direct'; + } else if (a === 'private' || b === 'private') { + return 'private'; + } else if (a === 'unlisted' || b === 'unlisted') { + return 'unlisted'; + } else { + return 'public'; + } +}; + +const hydrate = (state, hydratedState) => { + state = clearAll(state.merge(hydratedState)); + + if (hydratedState.has('text')) { + state = state.set('text', hydratedState.get('text')); + } + + return state; +}; + +export default function compose(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('compose')); + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state + .set('mounted', false) + .set('is_composing', false); + case COMPOSE_ADVANCED_OPTIONS_CHANGE: + return state + .set('advanced_options', + state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option]))) + .set('idempotencyKey', uuid()); + case COMPOSE_SENSITIVITY_CHANGE: + return state.withMutations(map => { + if (!state.get('spoiler')) { + map.set('sensitive', !state.get('sensitive')); + } + + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SPOILERNESS_CHANGE: + return state.withMutations(map => { + map.set('spoiler_text', ''); + map.set('spoiler', !state.get('spoiler')); + map.set('idempotencyKey', uuid()); + + if (!state.get('sensitive') && state.get('media_attachments').size >= 1) { + map.set('sensitive', true); + } + }); + case COMPOSE_SPOILER_TEXT_CHANGE: + return state + .set('spoiler_text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_VISIBILITY_CHANGE: + return state + .set('privacy', action.value) + .set('idempotencyKey', uuid()); + case COMPOSE_CHANGE: + return state + .set('text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_COMPOSING_CHANGE: + return state.set('is_composing', action.value); + case COMPOSE_REPLY: + return state.withMutations(map => { + map.set('in_reply_to', action.status.get('id')); + map.set('text', statusToTextMentions(state, action.status)); + map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('advanced_options', new ImmutableMap({ + do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')), + })); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + + if (action.status.get('spoiler_text').length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.status.get('spoiler_text')); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + }); + case COMPOSE_REPLY_CANCEL: + case COMPOSE_RESET: + return state.withMutations(map => { + map.set('in_reply_to', null); + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('privacy', state.get('default_privacy')); + map.set('advanced_options', state.get('default_advanced_options')); + 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.set('is_uploading', true); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, fromJS(action.media)); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false); + case COMPOSE_UPLOAD_UNDO: + return removeMedia(state, action.media_id); + case COMPOSE_UPLOAD_PROGRESS: + return state.set('progress', Math.round((action.loaded / action.total) * 100)); + case COMPOSE_MENTION: + return state + .update('text', text => `${text}@${action.account.get('acct')} `) + .set('focusDate', new Date()) + .set('idempotencyKey', uuid()); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion); + case TIMELINE_DELETE: + if (action.id === state.get('in_reply_to')) { + return state.set('in_reply_to', null); + } else { + return state; + } + 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; + })); + case COMPOSE_DOODLE_SET: + return state.mergeIn(['doodle'], action.options); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/contexts.js b/app/javascript/themes/glitch/reducers/contexts.js new file mode 100644 index 000000000..56c930bd5 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/contexts.js @@ -0,0 +1,61 @@ +import { CONTEXT_FETCH_SUCCESS } from 'themes/glitch/actions/statuses'; +import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from 'themes/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + ancestors: ImmutableMap(), + descendants: ImmutableMap(), +}); + +const normalizeContext = (state, id, ancestors, descendants) => { + 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); + map.setIn(['descendants', id], descendantsIds); + }); +}; + +const deleteFromContexts = (state, id) => { + state.getIn(['descendants', id], ImmutableList()).forEach(descendantId => { + state = state.updateIn(['ancestors', descendantId], ImmutableList(), list => list.filterNot(itemId => itemId === id)); + }); + + state.getIn(['ancestors', id], ImmutableList()).forEach(ancestorId => { + state = state.updateIn(['descendants', ancestorId], ImmutableList(), list => list.filterNot(itemId => itemId === id)); + }); + + state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', 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, 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/themes/glitch/reducers/custom_emojis.js b/app/javascript/themes/glitch/reducers/custom_emojis.js new file mode 100644 index 000000000..e3f1e0018 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/custom_emojis.js @@ -0,0 +1,16 @@ +import { List as ImmutableList } from 'immutable'; +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { search as emojiSearch } from 'themes/glitch/util/emoji/emoji_mart_search_light'; +import { buildCustomEmojis } from 'themes/glitch/util/emoji'; + +const initialState = ImmutableList(); + +export default function custom_emojis(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); + return action.state.get('custom_emojis'); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/height_cache.js b/app/javascript/themes/glitch/reducers/height_cache.js new file mode 100644 index 000000000..93c31b42c --- /dev/null +++ b/app/javascript/themes/glitch/reducers/height_cache.js @@ -0,0 +1,23 @@ +import { Map as ImmutableMap } from 'immutable'; +import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from 'themes/glitch/actions/height_cache'; + +const initialState = ImmutableMap(); + +const setHeight = (state, key, id, height) => { + return state.update(key, ImmutableMap(), map => map.set(id, height)); +}; + +const clearHeights = () => { + return ImmutableMap(); +}; + +export default function statuses(state = initialState, action) { + switch(action.type) { + case HEIGHT_CACHE_SET: + return setHeight(state, action.key, action.id, action.height); + case HEIGHT_CACHE_CLEAR: + return clearHeights(); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/index.js b/app/javascript/themes/glitch/reducers/index.js new file mode 100644 index 000000000..aa748421a --- /dev/null +++ b/app/javascript/themes/glitch/reducers/index.js @@ -0,0 +1,54 @@ +import { combineReducers } from 'redux-immutable'; +import timelines from './timelines'; +import meta from './meta'; +import alerts from './alerts'; +import { loadingBarReducer } from 'react-redux-loading-bar'; +import modal from './modal'; +import user_lists from './user_lists'; +import accounts from './accounts'; +import accounts_counters from './accounts_counters'; +import statuses from './statuses'; +import relationships from './relationships'; +import settings from './settings'; +import local_settings from './local_settings'; +import push_notifications from './push_notifications'; +import status_lists from './status_lists'; +import cards from './cards'; +import mutes from './mutes'; +import reports from './reports'; +import contexts from './contexts'; +import compose from './compose'; +import search from './search'; +import media_attachments from './media_attachments'; +import notifications from './notifications'; +import height_cache from './height_cache'; +import custom_emojis from './custom_emojis'; + +const reducers = { + timelines, + meta, + alerts, + loadingBar: loadingBarReducer, + modal, + user_lists, + status_lists, + accounts, + accounts_counters, + statuses, + relationships, + settings, + local_settings, + push_notifications, + cards, + mutes, + reports, + contexts, + compose, + search, + media_attachments, + notifications, + height_cache, + custom_emojis, +}; + +export default combineReducers(reducers); diff --git a/app/javascript/themes/glitch/reducers/local_settings.js b/app/javascript/themes/glitch/reducers/local_settings.js new file mode 100644 index 000000000..b1ffa047e --- /dev/null +++ b/app/javascript/themes/glitch/reducers/local_settings.js @@ -0,0 +1,45 @@ +// Package imports. +import { Map as ImmutableMap } from 'immutable'; + +// Our imports. +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { LOCAL_SETTING_CHANGE } from 'themes/glitch/actions/local_settings'; + +const initialState = ImmutableMap({ + layout : 'auto', + stretch : true, + navbar_under : false, + side_arm : 'none', + collapsed : ImmutableMap({ + enabled : true, + auto : ImmutableMap({ + all : false, + notifications : true, + lengthy : true, + reblogs : false, + replies : false, + media : false, + }), + backgrounds : ImmutableMap({ + user_backgrounds : false, + preview_images : false, + }), + }), + media : ImmutableMap({ + letterbox : true, + fullwidth : true, + }), +}); + +const hydrate = (state, localSettings) => state.mergeDeep(localSettings); + +export default function localSettings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('local_settings')); + case LOCAL_SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/media_attachments.js b/app/javascript/themes/glitch/reducers/media_attachments.js new file mode 100644 index 000000000..69a44639c --- /dev/null +++ b/app/javascript/themes/glitch/reducers/media_attachments.js @@ -0,0 +1,15 @@ +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { Map as ImmutableMap } from 'immutable'; + +const initialState = ImmutableMap({ + accept_content_types: [], +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('media_attachments')); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/meta.js b/app/javascript/themes/glitch/reducers/meta.js new file mode 100644 index 000000000..2249f1d78 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/meta.js @@ -0,0 +1,16 @@ +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { Map as ImmutableMap } from 'immutable'; + +const initialState = ImmutableMap({ + streaming_api_base_url: null, + access_token: null, +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('meta')); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/modal.js b/app/javascript/themes/glitch/reducers/modal.js new file mode 100644 index 000000000..97fb31203 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/modal.js @@ -0,0 +1,17 @@ +import { MODAL_OPEN, MODAL_CLOSE } from 'themes/glitch/actions/modal'; + +const initialState = { + modalType: null, + modalProps: {}, +}; + +export default function modal(state = initialState, action) { + switch(action.type) { + case MODAL_OPEN: + return { modalType: action.modalType, modalProps: action.modalProps }; + case MODAL_CLOSE: + return initialState; + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/mutes.js b/app/javascript/themes/glitch/reducers/mutes.js new file mode 100644 index 000000000..8fe4ae0c3 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/mutes.js @@ -0,0 +1,29 @@ +import Immutable from 'immutable'; + +import { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, +} from 'themes/glitch/actions/mutes'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account: null, + notifications: true, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case MUTES_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'isSubmitting'], false); + state.setIn(['new', 'account'], action.account); + state.setIn(['new', 'notifications'], true); + }); + case MUTES_TOGGLE_HIDE_NOTIFICATIONS: + return state.updateIn(['new', 'notifications'], (old) => !old); + default: + return state; + } +} diff --git a/app/javascript/themes/glitch/reducers/notifications.js b/app/javascript/themes/glitch/reducers/notifications.js new file mode 100644 index 000000000..c4f505053 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/notifications.js @@ -0,0 +1,191 @@ +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_REFRESH_REQUEST, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_REFRESH_FAIL, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_DELETE_MARKED_REQUEST, + NOTIFICATIONS_DELETE_MARKED_SUCCESS, + NOTIFICATION_MARK_FOR_DELETE, + NOTIFICATIONS_DELETE_MARKED_FAIL, + NOTIFICATIONS_ENTER_CLEARING_MODE, + NOTIFICATIONS_MARK_ALL_FOR_DELETE, +} from 'themes/glitch/actions/notifications'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from 'themes/glitch/actions/accounts'; +import { TIMELINE_DELETE } from 'themes/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + next: null, + top: true, + unread: 0, + loaded: false, + isLoading: true, + cleaningMode: false, + // notification removal mark of new notifs loaded whilst cleaningMode is true. + markNewForDelete: false, +}); + +const notificationToMap = (state, notification) => ImmutableMap({ + id: notification.id, + type: notification.type, + account: notification.account.id, + markedForDelete: state.get('markNewForDelete'), + status: notification.status ? notification.status.id : null, +}); + +const normalizeNotification = (state, notification) => { + const top = state.get('top'); + + if (!top) { + state = state.update('unread', unread => unread + 1); + } + + return state.update('items', list => { + if (top && list.size > 40) { + list = list.take(20); + } + + return list.unshift(notificationToMap(state, notification)); + }); +}; + +const normalizeNotifications = (state, notifications, next) => { + let items = ImmutableList(); + const loaded = state.get('loaded'); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(state, n)); + }); + + if (state.get('next') === null) { + state = state.set('next', next); + } + + return state + .update('items', list => loaded ? items.concat(list) : list.concat(items)) + .set('loaded', true) + .set('isLoading', false); +}; + +const appendNormalizedNotifications = (state, notifications, next) => { + let items = ImmutableList(); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(state, n)); + }); + + return state + .update('items', list => list.concat(items)) + .set('next', next) + .set('isLoading', false); +}; + +const filterNotifications = (state, relationship) => { + return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); +}; + +const updateTop = (state, top) => { + if (top) { + state = state.set('unread', 0); + } + + return state.set('top', top); +}; + +const deleteByStatus = (state, statusId) => { + return state.update('items', list => list.filterNot(item => item.get('status') === statusId)); +}; + +const markForDelete = (state, notificationId, yes) => { + return state.update('items', list => list.map(item => { + if(item.get('id') === notificationId) { + return item.set('markedForDelete', yes); + } else { + return item; + } + })); +}; + +const markAllForDelete = (state, yes) => { + return state.update('items', list => list.map(item => { + if(yes !== null) { + return item.set('markedForDelete', yes); + } else { + return item.set('markedForDelete', !item.get('markedForDelete')); + } + })); +}; + +const unmarkAllForDelete = (state) => { + return state.update('items', list => list.map(item => item.set('markedForDelete', false))); +}; + +const deleteMarkedNotifs = (state) => { + return state.update('items', list => list.filterNot(item => item.get('markedForDelete'))); +}; + +export default function notifications(state = initialState, action) { + let st; + + switch(action.type) { + case NOTIFICATIONS_REFRESH_REQUEST: + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_DELETE_MARKED_REQUEST: + return state.set('isLoading', true); + case NOTIFICATIONS_DELETE_MARKED_FAIL: + case NOTIFICATIONS_REFRESH_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.set('isLoading', false); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_REFRESH_SUCCESS: + return normalizeNotifications(state, action.notifications, action.next); + 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); + case TIMELINE_DELETE: + return deleteByStatus(state, action.id); + + case NOTIFICATION_MARK_FOR_DELETE: + return markForDelete(state, action.id, action.yes); + + case NOTIFICATIONS_DELETE_MARKED_SUCCESS: + return deleteMarkedNotifs(state).set('isLoading', false); + + case NOTIFICATIONS_ENTER_CLEARING_MODE: + st = state.set('cleaningMode', action.yes); + if (!action.yes) { + return unmarkAllForDelete(st).set('markNewForDelete', false); + } else { + return st; + } + + case NOTIFICATIONS_MARK_ALL_FOR_DELETE: + st = state; + if (action.yes === null) { + // Toggle - this is a bit confusing, as it toggles the all-none mode + //st = st.set('markNewForDelete', !st.get('markNewForDelete')); + } else { + st = st.set('markNewForDelete', action.yes); + } + return markAllForDelete(st, action.yes); + + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/push_notifications.js b/app/javascript/themes/glitch/reducers/push_notifications.js new file mode 100644 index 000000000..744e4a0eb --- /dev/null +++ b/app/javascript/themes/glitch/reducers/push_notifications.js @@ -0,0 +1,51 @@ +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from 'themes/glitch/actions/push_notifications'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + subscription: null, + alerts: new Immutable.Map({ + follow: false, + favourite: false, + reblog: false, + mention: false, + }), + isSubscribed: false, + browserSupport: false, +}); + +export default function push_subscriptions(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: { + const push_subscription = action.state.get('push_subscription'); + + if (push_subscription) { + return state + .set('subscription', new Immutable.Map({ + id: push_subscription.get('id'), + endpoint: push_subscription.get('endpoint'), + })) + .set('alerts', push_subscription.get('alerts') || initialState.get('alerts')) + .set('isSubscribed', true); + } + + return state; + } + case SET_SUBSCRIPTION: + return state + .set('subscription', new Immutable.Map({ + id: action.subscription.id, + endpoint: action.subscription.endpoint, + })) + .set('alerts', new Immutable.Map(action.subscription.alerts)) + .set('isSubscribed', true); + case SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case CLEAR_SUBSCRIPTION: + return initialState; + case ALERTS_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/relationships.js b/app/javascript/themes/glitch/reducers/relationships.js new file mode 100644 index 000000000..d9135d6da --- /dev/null +++ b/app/javascript/themes/glitch/reducers/relationships.js @@ -0,0 +1,46 @@ +import { + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNMUTE_SUCCESS, + RELATIONSHIPS_FETCH_SUCCESS, +} from 'themes/glitch/actions/accounts'; +import { + DOMAIN_BLOCK_SUCCESS, + DOMAIN_UNBLOCK_SUCCESS, +} from 'themes/glitch/actions/domain_blocks'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship)); + +const normalizeRelationships = (state, relationships) => { + relationships.forEach(relationship => { + state = normalizeRelationship(state, relationship); + }); + + return state; +}; + +const initialState = ImmutableMap(); + +export default function relationships(state = initialState, action) { + switch(action.type) { + case ACCOUNT_FOLLOW_SUCCESS: + case ACCOUNT_UNFOLLOW_SUCCESS: + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_UNBLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + case ACCOUNT_UNMUTE_SUCCESS: + return normalizeRelationship(state, action.relationship); + case RELATIONSHIPS_FETCH_SUCCESS: + return normalizeRelationships(state, action.relationships); + case DOMAIN_BLOCK_SUCCESS: + return state.setIn([action.accountId, 'domain_blocking'], true); + case DOMAIN_UNBLOCK_SUCCESS: + return state.setIn([action.accountId, 'domain_blocking'], false); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/reports.js b/app/javascript/themes/glitch/reducers/reports.js new file mode 100644 index 000000000..b714374ea --- /dev/null +++ b/app/javascript/themes/glitch/reducers/reports.js @@ -0,0 +1,60 @@ +import { + REPORT_INIT, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_CANCEL, + REPORT_STATUS_TOGGLE, + REPORT_COMMENT_CHANGE, +} from 'themes/glitch/actions/reports'; +import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; + +const initialState = ImmutableMap({ + new: ImmutableMap({ + isSubmitting: false, + account_id: null, + status_ids: ImmutableSet(), + comment: '', + }), +}); + +export default function reports(state = initialState, action) { + switch(action.type) { + case REPORT_INIT: + return state.withMutations(map => { + map.setIn(['new', 'isSubmitting'], false); + map.setIn(['new', 'account_id'], action.account.get('id')); + + if (state.getIn(['new', 'account_id']) !== action.account.get('id')) { + map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet()); + map.setIn(['new', 'comment'], ''); + } else if (action.status) { + map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); + } + }); + case REPORT_STATUS_TOGGLE: + return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => { + if (action.checked) { + return set.add(action.statusId); + } + + return set.remove(action.statusId); + }); + case REPORT_COMMENT_CHANGE: + return state.setIn(['new', 'comment'], action.comment); + case REPORT_SUBMIT_REQUEST: + return state.setIn(['new', 'isSubmitting'], true); + case REPORT_SUBMIT_FAIL: + return state.setIn(['new', 'isSubmitting'], false); + case REPORT_CANCEL: + case REPORT_SUBMIT_SUCCESS: + return state.withMutations(map => { + map.setIn(['new', 'account_id'], null); + map.setIn(['new', 'status_ids'], ImmutableSet()); + map.setIn(['new', 'comment'], ''); + map.setIn(['new', 'isSubmitting'], false); + }); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/search.js b/app/javascript/themes/glitch/reducers/search.js new file mode 100644 index 000000000..aec9e2efb --- /dev/null +++ b/app/javascript/themes/glitch/reducers/search.js @@ -0,0 +1,42 @@ +import { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW, +} from 'themes/glitch/actions/search'; +import { COMPOSE_MENTION, COMPOSE_REPLY } from 'themes/glitch/actions/compose'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + value: '', + submitted: false, + hidden: false, + results: ImmutableMap(), +}); + +export default function search(state = initialState, action) { + switch(action.type) { + case SEARCH_CHANGE: + return state.set('value', action.value); + case SEARCH_CLEAR: + return state.withMutations(map => { + map.set('value', ''); + map.set('results', ImmutableMap()); + map.set('submitted', false); + map.set('hidden', false); + }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + return state.set('hidden', true); + case SEARCH_FETCH_SUCCESS: + return state.set('results', ImmutableMap({ + accounts: ImmutableList(action.results.accounts.map(item => item.id)), + statuses: ImmutableList(action.results.statuses.map(item => item.id)), + hashtags: ImmutableList(action.results.hashtags), + })).set('submitted', true); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/settings.js b/app/javascript/themes/glitch/reducers/settings.js new file mode 100644 index 000000000..c22bbbd8d --- /dev/null +++ b/app/javascript/themes/glitch/reducers/settings.js @@ -0,0 +1,119 @@ +import { SETTING_CHANGE, SETTING_SAVE } from 'themes/glitch/actions/settings'; +import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from 'themes/glitch/actions/columns'; +import { STORE_HYDRATE } from 'themes/glitch/actions/store'; +import { EMOJI_USE } from 'themes/glitch/actions/emojis'; +import { Map as ImmutableMap, fromJS } from 'immutable'; +import uuid from 'themes/glitch/util/uuid'; + +const initialState = ImmutableMap({ + saved: true, + + onboarded: false, + layout: 'auto', + + skinTone: 1, + + home: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + }), + + regex: ImmutableMap({ + body: '', + }), + }), + + notifications: ImmutableMap({ + alerts: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + }), + + shows: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + }), + + sounds: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + }), + }), + + community: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + public: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + direct: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), +}); + +const defaultColumns = fromJS([ + { id: 'COMPOSE', uuid: uuid(), params: {} }, + { id: 'HOME', uuid: uuid(), params: {} }, + { id: 'NOTIFICATIONS', uuid: uuid(), params: {} }, +]); + +const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val); + +const moveColumn = (state, uuid, direction) => { + const columns = state.get('columns'); + const index = columns.findIndex(item => item.get('uuid') === uuid); + const newIndex = index + direction; + + let newColumns; + + newColumns = columns.splice(index, 1); + newColumns = newColumns.splice(newIndex, 0, columns.get(index)); + + 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) + .set('saved', false); + case COLUMN_ADD: + 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)) + .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/themes/glitch/reducers/status_lists.js b/app/javascript/themes/glitch/reducers/status_lists.js new file mode 100644 index 000000000..8dc7d374e --- /dev/null +++ b/app/javascript/themes/glitch/reducers/status_lists.js @@ -0,0 +1,75 @@ +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS, +} from 'themes/glitch/actions/favourites'; +import { + PINNED_STATUSES_FETCH_SUCCESS, +} from 'themes/glitch/actions/pin_statuses'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, +} from 'themes/glitch/actions/interactions'; + +const initialState = ImmutableMap({ + favourites: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), + pins: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('items', ImmutableList(statuses.map(item => item.id))); + })); +}; + +const appendToList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('items', map.get('items').concat(statuses.map(item => item.id))); + })); +}; + +const prependOneToList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').unshift(status.get('id'))); + })); +}; + +const removeOneFromList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').filter(item => item !== status.get('id'))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'favourites', action.statuses, action.next); + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'favourites', action.statuses, action.next); + case FAVOURITE_SUCCESS: + return prependOneToList(state, 'favourites', action.status); + case UNFAVOURITE_SUCCESS: + return removeOneFromList(state, 'favourites', action.status); + case PINNED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'pins', action.statuses, action.next); + case PIN_SUCCESS: + return prependOneToList(state, 'pins', action.status); + case UNPIN_SUCCESS: + return removeOneFromList(state, 'pins', action.status); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/statuses.js b/app/javascript/themes/glitch/reducers/statuses.js new file mode 100644 index 000000000..ef8086865 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/statuses.js @@ -0,0 +1,148 @@ +import { + REBLOG_REQUEST, + REBLOG_SUCCESS, + REBLOG_FAIL, + UNREBLOG_SUCCESS, + FAVOURITE_REQUEST, + FAVOURITE_SUCCESS, + FAVOURITE_FAIL, + UNFAVOURITE_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, +} from 'themes/glitch/actions/interactions'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS, + STATUS_MUTE_SUCCESS, + STATUS_UNMUTE_SUCCESS, +} from 'themes/glitch/actions/statuses'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS, +} from 'themes/glitch/actions/timelines'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from 'themes/glitch/actions/accounts'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS, +} from 'themes/glitch/actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS, +} from 'themes/glitch/actions/favourites'; +import { + PINNED_STATUSES_FETCH_SUCCESS, +} from 'themes/glitch/actions/pin_statuses'; +import { SEARCH_FETCH_SUCCESS } from 'themes/glitch/actions/search'; +import emojify from 'themes/glitch/util/emoji'; +import { Map as ImmutableMap, fromJS } from 'immutable'; +import escapeTextContentForBrowser from 'escape-html'; + +const domParser = new DOMParser(); + +const normalizeStatus = (state, status) => { + if (!status) { + return state; + } + + const normalStatus = { ...status }; + normalStatus.account = status.account.id; + + if (status.reblog && status.reblog.id) { + state = normalizeStatus(state, status.reblog); + normalStatus.reblog = status.reblog.id; + } + + const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + + const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; + }, {}); + + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; + normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); + + return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus))); +}; + +const normalizeStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeStatus(state, status); + }); + + return state; +}; + +const deleteStatus = (state, id, references) => { + references.forEach(ref => { + state = deleteStatus(state, ref[0], []); + }); + + return state.delete(id); +}; + +const filterStatuses = (state, relationship) => { + state.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id'))); + }); + + return state; +}; + +const initialState = ImmutableMap(); + +export default function statuses(state = initialState, action) { + switch(action.type) { + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeStatus(state, action.status); + case REBLOG_SUCCESS: + case UNREBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNFAVOURITE_SUCCESS: + case PIN_SUCCESS: + case UNPIN_SUCCESS: + return normalizeStatus(state, action.response); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.setIn([action.status.get('id'), 'favourited'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.setIn([action.status.get('id'), 'reblogged'], false); + case STATUS_MUTE_SUCCESS: + return state.setIn([action.id, 'muted'], true); + case STATUS_UNMUTE_SUCCESS: + return state.setIn([action.id, 'muted'], false); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + case PINNED_STATUSES_FETCH_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterStatuses(state, action.relationship); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/timelines.js b/app/javascript/themes/glitch/reducers/timelines.js new file mode 100644 index 000000000..7f19a1897 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/timelines.js @@ -0,0 +1,149 @@ +import { + TIMELINE_REFRESH_REQUEST, + TIMELINE_REFRESH_SUCCESS, + TIMELINE_REFRESH_FAIL, + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, + TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT, +} from 'themes/glitch/actions/timelines'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, +} from 'themes/glitch/actions/accounts'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const initialTimeline = ImmutableMap({ + unread: 0, + online: false, + top: true, + loaded: false, + isLoading: false, + next: false, + items: ImmutableList(), +}); + +const normalizeTimeline = (state, timeline, statuses, next) => { + const oldIds = state.getIn([timeline, 'items'], ImmutableList()); + const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); + const wasLoaded = state.getIn([timeline, 'loaded']); + const hadNext = state.getIn([timeline, 'next']); + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + mMap.set('loaded', true); + mMap.set('isLoading', false); + if (!hadNext) mMap.set('next', next); + mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids); + })); +}; + +const appendNormalizedTimeline = (state, timeline, statuses, next) => { + const oldIds = state.getIn([timeline, 'items'], ImmutableList()); + const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + mMap.set('isLoading', false); + mMap.set('next', next); + mMap.set('items', oldIds.concat(ids)); + })); +}; + +const updateTimeline = (state, timeline, status, references) => { + const top = state.getIn([timeline, 'top']); + const ids = state.getIn([timeline, 'items'], ImmutableList()); + const includesId = ids.includes(status.get('id')); + const unread = state.getIn([timeline, 'unread'], 0); + + if (includesId) { + return state; + } + + let newIds = ids; + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (!top) mMap.set('unread', unread + 1); + if (top && ids.size > 40) newIds = newIds.take(20); + if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item)); + mMap.set('items', newIds.unshift(status.get('id'))); + })); +}; + +const deleteStatus = (state, id, accountId, references) => { + state.keySeq().forEach(timeline => { + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); + }); + + // Remove reblogs of deleted status + references.forEach(ref => { + state = deleteStatus(state, ref[0], ref[1], []); + }); + + return state; +}; + +const filterTimelines = (state, relationship, statuses) => { + let references; + + statuses.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]); + state = deleteStatus(state, status.get('id'), status.get('account'), references); + }); + + return state; +}; + +const filterTimeline = (timeline, state, relationship, statuses) => + state.updateIn([timeline, 'items'], ImmutableList(), list => + list.filterNot(statusId => + statuses.getIn([statusId, 'account']) === relationship.id + )); + +const updateTop = (state, timeline, top) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (top) mMap.set('unread', 0); + mMap.set('top', top); + })); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_REFRESH_REQUEST: + case TIMELINE_EXPAND_REQUEST: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); + case TIMELINE_REFRESH_FAIL: + case TIMELINE_EXPAND_FAIL: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); + case TIMELINE_REFRESH_SUCCESS: + return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next); + case TIMELINE_EXPAND_SUCCESS: + return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, fromJS(action.status), action.references); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case ACCOUNT_UNFOLLOW_SUCCESS: + return filterTimeline('home', state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.update(action.timeline, initialTimeline, map => map.set('online', true)); + case TIMELINE_DISCONNECT: + return state.update(action.timeline, initialTimeline, map => map.set('online', false)); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/reducers/user_lists.js b/app/javascript/themes/glitch/reducers/user_lists.js new file mode 100644 index 000000000..8c3a7d748 --- /dev/null +++ b/app/javascript/themes/glitch/reducers/user_lists.js @@ -0,0 +1,80 @@ +import { + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_REJECT_SUCCESS, +} from 'themes/glitch/actions/accounts'; +import { + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS, +} from 'themes/glitch/actions/interactions'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS, +} from 'themes/glitch/actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS, +} from 'themes/glitch/actions/mutes'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + followers: ImmutableMap(), + following: ImmutableMap(), + reblogged_by: ImmutableMap(), + favourited_by: ImmutableMap(), + follow_requests: ImmutableMap(), + blocks: ImmutableMap(), + mutes: ImmutableMap(), +}); + +const normalizeList = (state, type, id, accounts, next) => { + return state.setIn([type, id], ImmutableMap({ + next, + items: ImmutableList(accounts.map(item => item.id)), + })); +}; + +const appendToList = (state, type, id, accounts, next) => { + return state.updateIn([type, id], map => { + return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id))); + }); +}; + +export default function userLists(state = initialState, action) { + switch(action.type) { + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, 'following', action.id, action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, 'following', action.id, action.accounts, action.next); + case REBLOGS_FETCH_SUCCESS: + return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + case BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case MUTES_FETCH_SUCCESS: + return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + case MUTES_EXPAND_SUCCESS: + return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + default: + return state; + } +}; diff --git a/app/javascript/themes/glitch/selectors/index.js b/app/javascript/themes/glitch/selectors/index.js new file mode 100644 index 000000000..d26d1b727 --- /dev/null +++ b/app/javascript/themes/glitch/selectors/index.js @@ -0,0 +1,87 @@ +import { createSelector } from 'reselect'; +import { List as ImmutableList } from 'immutable'; + +const getAccountBase = (state, id) => state.getIn(['accounts', id], null); +const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); +const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); + +export const makeGetAccount = () => { + return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => { + if (base === null) { + return null; + } + + return base.merge(counters).set('relationship', relationship); + }); +}; + +export const makeGetStatus = () => { + return createSelector( + [ + (state, id) => state.getIn(['statuses', id]), + (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + ], + + (statusBase, statusReblog, accountBase, accountReblog) => { + if (!statusBase) { + return null; + } + + if (statusReblog) { + statusReblog = statusReblog.set('account', accountReblog); + } else { + statusReblog = null; + } + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog); + map.set('account', accountBase); + }); + } + ); +}; + +const getAlertsBase = state => state.get('alerts'); + +export const getAlerts = createSelector([getAlertsBase], (base) => { + let arr = []; + + base.forEach(item => { + arr.push({ + message: item.get('message'), + title: item.get('title'), + key: item.get('key'), + dismissAfter: 5000, + barStyle: { + zIndex: 200, + }, + }); + }); + + return arr; +}); + +export const makeGetNotification = () => { + return createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]), + ], (base, account) => { + return base.set('account', account); + }); +}; + +export const getAccountGallery = createSelector([ + (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), + state => state.get('statuses'), +], (statusIds, statuses) => { + let medias = ImmutableList(); + + statusIds.forEach(statusId => { + const status = statuses.get(statusId); + medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status))); + }); + + return medias; +}); diff --git a/app/javascript/themes/glitch/service_worker/entry.js b/app/javascript/themes/glitch/service_worker/entry.js new file mode 100644 index 000000000..eea4cfc3c --- /dev/null +++ b/app/javascript/themes/glitch/service_worker/entry.js @@ -0,0 +1,10 @@ +import './web_push_notifications'; + +// Cause a new version of a registered Service Worker to replace an existing one +// that is already installed, and replace the currently active worker on open pages. +self.addEventListener('install', function(event) { + event.waitUntil(self.skipWaiting()); +}); +self.addEventListener('activate', function(event) { + event.waitUntil(self.clients.claim()); +}); diff --git a/app/javascript/themes/glitch/service_worker/web_push_notifications.js b/app/javascript/themes/glitch/service_worker/web_push_notifications.js new file mode 100644 index 000000000..f63cff335 --- /dev/null +++ b/app/javascript/themes/glitch/service_worker/web_push_notifications.js @@ -0,0 +1,159 @@ +const MAX_NOTIFICATIONS = 5; +const GROUP_TAG = 'tag'; + +// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker +const formatGroupTitle = (message, count) => message.replace('%{count}', count); + +const notify = options => + self.registration.getNotifications().then(notifications => { + if (notifications.length === MAX_NOTIFICATIONS) { + // Reached the maximum number of notifications, proceed with grouping + const group = { + title: formatGroupTitle(options.data.message, notifications.length + 1), + body: notifications + .sort((n1, n2) => n1.timestamp < n2.timestamp) + .map(notification => notification.title).join('\n'), + badge: '/badge.png', + icon: '/android-chrome-192x192.png', + tag: GROUP_TAG, + data: { + url: (new URL('/web/notifications', self.location)).href, + count: notifications.length + 1, + message: options.data.message, + }, + }; + + notifications.forEach(notification => notification.close()); + + return self.registration.showNotification(group.title, group); + } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) { + // Already grouped, proceed with appending the notification to the group + const group = cloneNotification(notifications[0]); + + group.title = formatGroupTitle(group.data.message, group.data.count + 1); + group.body = `${options.title}\n${group.body}`; + group.data = { ...group.data, count: group.data.count + 1 }; + + return self.registration.showNotification(group.title, group); + } + + return self.registration.showNotification(options.title, options); + }); + +const handlePush = (event) => { + const options = event.data.json(); + + options.body = options.data.nsfw || options.data.content; + options.dir = options.data.dir; + options.image = options.image || undefined; // Null results in a network request (404) + options.timestamp = options.timestamp && new Date(options.timestamp); + + const expandAction = options.data.actions.find(action => action.todo === 'expand'); + + if (expandAction) { + options.actions = [expandAction]; + options.hiddenActions = options.data.actions.filter(action => action !== expandAction); + options.data.hiddenImage = options.image; + options.image = undefined; + } else { + options.actions = options.data.actions; + } + + event.waitUntil(notify(options)); +}; + +const cloneNotification = (notification) => { + const clone = { }; + + for(var k in notification) { + clone[k] = notification[k]; + } + + return clone; +}; + +const expandNotification = (notification) => { + const nextNotification = cloneNotification(notification); + + nextNotification.body = notification.data.content; + nextNotification.image = notification.data.hiddenImage; + nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand'); + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const makeRequest = (notification, action) => + fetch(action.action, { + headers: { + 'Authorization': `Bearer ${notification.data.access_token}`, + 'Content-Type': 'application/json', + }, + method: action.method, + credentials: 'include', + }); + +const findBestClient = clients => { + const focusedClient = clients.find(client => client.focused); + const visibleClient = clients.find(client => client.visibilityState === 'visible'); + + return focusedClient || visibleClient || clients[0]; +}; + +const openUrl = url => + self.clients.matchAll({ type: 'window' }).then(clientList => { + if (clientList.length !== 0) { + const webClients = clientList.filter(client => /\/web\//.test(client.url)); + + if (webClients.length !== 0) { + const client = findBestClient(webClients); + const { pathname } = new URL(url); + + if (pathname.startsWith('/web/')) { + return client.focus().then(client => client.postMessage({ + type: 'navigate', + path: pathname.slice('/web/'.length - 1), + })); + } + } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate + const client = findBestClient(clientList); + + return client.navigate(url).then(client => client.focus()); + } + } + + return self.clients.openWindow(url); + }); + +const removeActionFromNotification = (notification, action) => { + const actions = notification.actions.filter(act => act.action !== action.action); + const nextNotification = cloneNotification(notification); + + nextNotification.actions = actions; + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const handleNotificationClick = (event) => { + const reactToNotificationClick = new Promise((resolve, reject) => { + if (event.action) { + const action = event.notification.data.actions.find(({ action }) => action === event.action); + + if (action.todo === 'expand') { + resolve(expandNotification(event.notification)); + } else if (action.todo === 'request') { + resolve(makeRequest(event.notification, action) + .then(() => removeActionFromNotification(event.notification, action))); + } else { + reject(`Unknown action: ${action.todo}`); + } + } else { + event.notification.close(); + resolve(openUrl(event.notification.data.url)); + } + }); + + event.waitUntil(reactToNotificationClick); +}; + +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/javascript/themes/glitch/store/configureStore.js b/app/javascript/themes/glitch/store/configureStore.js new file mode 100644 index 000000000..1376d4cba --- /dev/null +++ b/app/javascript/themes/glitch/store/configureStore.js @@ -0,0 +1,15 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import appReducer from '../reducers'; +import loadingBarMiddleware from '../middleware/loading_bar'; +import errorsMiddleware from '../middleware/errors'; +import soundsMiddleware from '../middleware/sounds'; + +export default function configureStore() { + return createStore(appReducer, compose(applyMiddleware( + thunk, + loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), + errorsMiddleware(), + soundsMiddleware() + ), window.devToolsExtension ? window.devToolsExtension() : f => f)); +}; diff --git a/app/javascript/themes/glitch/styles/_mixins.scss b/app/javascript/themes/glitch/styles/_mixins.scss new file mode 100644 index 000000000..79a8149fd --- /dev/null +++ b/app/javascript/themes/glitch/styles/_mixins.scss @@ -0,0 +1,51 @@ +@mixin avatar-radius() { + border-radius: $ui-avatar-border-size; + background: transparent no-repeat; + background-position: 50%; + background-clip: padding-box; +} + +@mixin avatar-size($size:48px) { + width: $size; + height: $size; + background-size: $size $size; +} + +@mixin single-column($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .single-column #{$parent} { + @content; + } +} + +@mixin limited-single-column($media, $parent: '&') { + .auto-columns #{$parent}, .single-column #{$parent} { + @media #{$media} { + @content; + } + } +} + +@mixin multi-columns($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .multi-columns #{$parent} { + @content; + } +} + +@mixin fullwidth-gallery { + &.full-width { + margin-left: -22px; + margin-right: -22px; + width: inherit; + height: 250px; + } +} diff --git a/app/javascript/themes/glitch/styles/about.scss b/app/javascript/themes/glitch/styles/about.scss new file mode 100644 index 000000000..4ec689427 --- /dev/null +++ b/app/javascript/themes/glitch/styles/about.scss @@ -0,0 +1,822 @@ +.landing-page { + p, + li { + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + margin-bottom: 12px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + em { + display: inline; + margin: 0; + padding: 0; + font-weight: 500; + background: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: lighten($ui-primary-color, 10%); + } + + h1 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 26px; + line-height: 30px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + + small { + font-family: 'mastodon-font-sans-serif', sans-serif; + display: block; + font-size: 18px; + font-weight: 400; + color: $ui-base-lighter-color; + } + } + + h2 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 22px; + line-height: 26px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h3 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 18px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h4 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h5 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h6 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 12px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + ul, + ol { + margin-left: 20px; + + &[type='a'] { + list-style-type: lower-alpha; + } + + &[type='i'] { + list-style-type: lower-roman; + } + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li > ol, + li > ul { + margin-top: 6px; + } + + hr { + border-color: rgba($ui-base-lighter-color, .6); + } + + .container { + width: 100%; + box-sizing: border-box; + max-width: 800px; + margin: 0 auto; + word-wrap: break-word; + } + + .header-wrapper { + padding-top: 15px; + background: $ui-base-color; + background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color); + position: relative; + + &.compact { + background: $ui-base-color; + padding-bottom: 15px; + + .hero .heading { + padding-bottom: 20px; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + } + + .mascot-container { + max-width: 800px; + margin: 0 auto; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; + } + + .mascot { + position: absolute; + bottom: -14px; + width: auto; + height: auto; + left: 60px; + z-index: 3; + } + } + + .header { + line-height: 30px; + overflow: hidden; + + .container { + display: flex; + justify-content: space-between; + } + + .links { + position: relative; + z-index: 4; + + a { + display: flex; + justify-content: center; + align-items: center; + color: $ui-primary-color; + text-decoration: none; + padding: 12px 16px; + line-height: 32px; + font-family: 'mastodon-font-display', sans-serif; + font-weight: 500; + font-size: 14px; + + &:hover { + color: $ui-secondary-color; + } + } + + .brand { + a { + padding-left: 0; + padding-right: 0; + color: $white; + } + + img { + height: 32px; + position: relative; + top: 4px; + left: -10px; + } + } + + ul { + list-style: none; + margin: 0; + + li { + display: inline-block; + vertical-align: bottom; + margin: 0; + + &:first-child a { + padding-left: 0; + } + + &:last-child a { + padding-right: 0; + } + } + } + } + + .hero { + margin-top: 50px; + align-items: center; + position: relative; + + .floats { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + + div { + position: absolute; + transition: all 0.1s linear; + animation-name: floating; + animation-iteration-count: infinite; + animation-direction: alternate; + animation-timing-function: ease-in-out; + z-index: 2; + } + + .float-1 { + width: 324px; + height: 170px; + right: -120px; + bottom: 0; + animation-duration: 3s; + background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 447.1875 234.375" height="170" width="324"><path fill="#{hex-color($ui-base-lighter-color)}" d="M21.69 233.366c-6.45-1.268-13.347-5.63-16.704-10.564-10.705-15.734-1.513-37.724 18.632-44.57l4.8-1.632.173-17.753c.146-14.77.515-19.063 2.2-25.55 6.736-25.944 24.46-46.032 47.766-54.137 11.913-4.143 19.558-5.366 34.178-5.47l13.828-.096V71.12c0-4.755 2.853-17.457 5.238-23.327 8.588-21.137 26.735-35.957 52.153-42.593 23.248-6.07 50.153-6.415 71.863-.923 11.14 2.82 25.686 9.957 33.857 16.615 19.335 15.756 31.82 41.05 35.183 71.275.59 5.305.672 5.435 3.11 4.926 11.833-2.474 30.4-3.132 40.065-1.42 24.388 4.32 40.568 19.076 47.214 43.058 2.16 7.8 3.953 23.894 3.59 32.237l-.24 5.498 5.156 1.317c6.392 1.633 14.55 7.098 18.003 12.062 1.435 2.062 3.305 6.597 4.156 10.078 1.428 5.84 1.43 6.8.04 12.44-1.807 7.318-5.672 13.252-10.872 16.694-8.508 5.63 3.756 5.33-211.916 5.216-108.56-.056-199.22-.464-201.47-.906z"/></svg>'); + } + + .float-2 { + width: 241px; + height: 100px; + right: 210px; + bottom: 0; + animation-duration: 3.5s; + animation-delay: 0.2s; + background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 536.25 222.1875" height="100" width="241"><path fill="#{hex-color($ui-base-lighter-color)}" d="M42.626 221.23c-14.104-1.174-26.442-5.133-32.825-10.534-4.194-3.548-7.684-10.66-8.868-18.075-1.934-12.102.633-22.265 7.528-29.81 7.61-8.328 19.998-12.76 39.855-14.257l8.47-.638-2.08-6.223c-4.826-14.422-6.357-24.813-6.37-43.255-.012-14.923.28-18.513 2.1-25.724 2.283-9.048 8.483-23.034 13.345-30.1 14.76-21.45 43.505-38.425 70.535-41.65 30.628-3.655 64.47 12.073 89.668 41.673l5.955 6.995 2.765-4.174c1.52-2.296 5.74-6.93 9.376-10.295 18.382-17.02 43.436-20.676 73.352-10.705 12.158 4.052 21.315 9.53 29.64 17.733 12.752 12.562 18.16 25.718 18.19 44.26l.02 10.98 2.312-3.01c15.64-20.365 42.29-20.485 62.438-.28 3.644 3.653 7.558 8.593 8.697 10.976 4.895 10.24 5.932 25.688 2.486 37.046-.76 2.507-1.388 4.816-1.393 5.13-.006.316 6.845.87 15.224 1.234 53.06 2.297 76.356 12.98 81.817 37.526 3.554 15.973-3.71 28.604-19.566 34.02-4.554 1.555-17.922 1.655-234.517 1.757-126.327.06-233.497-.21-238.154-.597z"/></svg>'); + } + + .float-3 { + width: 267px; + height: 140px; + right: 110px; + top: -30px; + animation-duration: 4s; + animation-delay: 0.5s; + background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388.125 202.5" height="140" width="267"><path fill="#{hex-color($ui-base-lighter-color)}" d="M181.37 201.458c-17.184-1.81-36.762-8.944-49.523-18.05l-5.774-4.12-8.074 2.63c-11.468 3.738-21.382 4.962-35.815 4.422-14.79-.554-24.577-2.845-36.716-8.594-15.483-7.332-28.498-19.98-35.985-34.968C2.44 128.675-.94 108.435.9 91.356c3.362-31.234 18.197-53.698 43.63-66.074 12.803-6.23 22.384-8.55 37.655-9.122 14.433-.54 24.347.684 35.814 4.42l8.073 2.633 5.635-4.01c24.81-17.656 60.007-23.332 92.914-14.985 10.11 2.565 25.498 9.62 33.102 15.178l5.068 3.704 7.632-2.564c10.89-3.66 21.086-4.916 35.516-4.376 45.816 1.716 76.422 30.03 81.285 75.196 1.84 17.08-1.54 37.32-8.585 51.422-7.487 14.99-20.502 27.636-35.984 34.968-12.14 5.75-21.926 8.04-36.716 8.593-14.43.54-24.626-.716-35.516-4.376l-7.632-2.564-5.068 3.704c-12.844 9.387-32.714 16.488-51.545 18.42-10.607 1.09-13.916 1.08-24.81-.066z"/></svg>'); + } + } + + .heading { + position: relative; + z-index: 4; + padding-bottom: 150px; + } + + .simple_form, + .closed-registrations-message { + background: darken($ui-base-color, 4%); + width: 280px; + padding: 15px 20px; + border-radius: 4px 4px 0 0; + line-height: initial; + position: relative; + z-index: 4; + + .actions { + margin-bottom: 0; + + button, + .button, + .block-button { + margin-bottom: 0; + } + } + } + + .closed-registrations-message { + min-height: 330px; + display: flex; + flex-direction: column; + justify-content: space-between; + } + } + } + + .about-short { + background: darken($ui-base-color, 4%); + padding: 50px 0 30px; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + .information-board { + background: darken($ui-base-color, 4%); + padding: 20px 0; + + .container { + position: relative; + padding-right: 280px + 15px; + } + + .information-board-sections { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + } + + .section { + flex: 1 0 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + line-height: 28px; + color: $primary-text-color; + text-align: right; + padding: 10px 15px; + + span, + strong { + display: block; + } + + span { + &:last-child { + color: $ui-secondary-color; + } + } + + strong { + font-weight: 500; + font-size: 32px; + line-height: 48px; + } + } + + .panel { + position: absolute; + width: 280px; + box-sizing: border-box; + background: darken($ui-base-color, 8%); + padding: 20px; + padding-top: 10px; + border-radius: 4px 4px 0 0; + right: 0; + bottom: -40px; + + .panel-header { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: $ui-primary-color; + padding-bottom: 5px; + margin-bottom: 15px; + border-bottom: 1px solid lighten($ui-base-color, 4%); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + a, + span { + font-weight: 400; + color: darken($ui-primary-color, 10%); + } + + a { + text-decoration: none; + } + } + } + + .owner { + text-align: center; + + .avatar { + @include avatar-size(80px); + margin: 0 auto; + margin-bottom: 15px; + + img { + @include avatar-radius(); + @include avatar-size(80px); + display: block; + } + } + + .name { + font-size: 14px; + + a { + display: block; + color: $primary-text-color; + text-decoration: none; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + + .username { + display: block; + color: $ui-primary-color; + } + } + } + } + + .features { + padding: 50px 0; + + .container { + display: flex; + } + + #mastodon-timeline { + display: flex; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 13px; + line-height: 18px; + font-weight: 400; + color: $primary-text-color; + width: 330px; + margin-right: 30px; + flex: 0 0 auto; + background: $ui-base-color; + overflow: hidden; + border-radius: 4px; + box-shadow: 0 0 6px rgba($black, 0.1); + + .column-header { + color: inherit; + font-family: inherit; + font-size: 16px; + line-height: inherit; + font-weight: inherit; + margin: 0; + padding: 15px; + } + + .column { + padding: 0; + border-radius: 4px; + overflow: hidden; + } + + .scrollable { + height: 400px; + } + + p { + font-size: inherit; + line-height: inherit; + font-weight: inherit; + color: $primary-text-color; + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + a { + color: $ui-secondary-color; + text-decoration: none; + } + } + } + + .about-mastodon { + max-width: 675px; + + p { + margin-bottom: 20px; + } + + .features-list { + margin-top: 20px; + + .features-list__row { + display: flex; + padding: 10px 0; + justify-content: space-between; + + &:first-child { + padding-top: 0; + } + + .visual { + flex: 0 0 auto; + display: flex; + align-items: center; + margin-left: 15px; + + .fa { + display: block; + color: $ui-primary-color; + font-size: 48px; + } + } + + .text { + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + h6 { + font-size: inherit; + line-height: inherit; + margin-bottom: 0; + } + } + } + } + } + } + + .extended-description { + padding: 50px 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + .footer-links { + padding-bottom: 50px; + text-align: right; + color: $ui-base-lighter-color; + + p { + font-size: 14px; + } + + a { + color: inherit; + text-decoration: underline; + } + } + + @media screen and (max-width: 840px) { + .container { + padding: 0 20px; + } + + .information-board { + + .container { + padding-right: 20px; + } + + .section { + text-align: center; + } + + .panel { + position: static; + margin-top: 20px; + width: 100%; + border-radius: 4px; + + .panel-header { + text-align: center; + } + } + } + + .header-wrapper .mascot { + left: 20px; + } + } + + @media screen and (max-width: 689px) { + .header-wrapper .mascot { + display: none; + } + } + + @media screen and (max-width: 675px) { + .header-wrapper { + padding-top: 0; + + &.compact { + padding-bottom: 0; + } + + &.compact .hero .heading { + text-align: initial; + } + } + + .header .container, + .features .container { + display: block; + } + + .header { + + .links { + padding-top: 15px; + background: darken($ui-base-color, 4%); + + a { + padding: 12px 8px; + } + + .nav { + display: flex; + flex-flow: row wrap; + justify-content: space-around; + } + + .brand img { + left: 0; + top: 0; + } + } + + .hero { + margin-top: 30px; + padding: 0; + + .floats { + display: none; + } + + .heading { + padding: 30px 20px; + text-align: center; + } + + .simple_form, + .closed-registrations-message { + background: darken($ui-base-color, 8%); + width: 100%; + border-radius: 0; + box-sizing: border-box; + } + } + } + + .features #mastodon-timeline { + height: 70vh; + width: 100%; + margin-bottom: 50px; + + .column { + width: 100%; + } + } + } + + .cta { + margin: 20px; + } + + &.tag-page { + .features { + padding: 30px 0; + + .container { + max-width: 820px; + + #mastodon-timeline { + margin-right: 0; + border-top-right-radius: 0; + } + + .about-mastodon { + .about-hashtag { + background: darken($ui-base-color, 4%); + padding: 0 20px 20px 30px; + border-radius: 0 5px 5px 0; + + .brand { + padding-top: 20px; + margin-bottom: 20px; + + img { + height: 48px; + width: auto; + } + } + + p { + strong { + color: $ui-secondary-color; + font-weight: 700; + } + } + + .cta { + margin: 0; + + .button { + margin-right: 4px; + } + } + } + + .features-list { + margin-left: 30px; + margin-right: 10px; + } + } + } + } + + @media screen and (max-width: 675px) { + .features { + padding: 10px 0; + + .container { + display: flex; + flex-direction: column; + + #mastodon-timeline { + order: 2; + flex: 0 0 auto; + height: 60vh; + margin-bottom: 20px; + border-top-right-radius: 4px; + } + + .about-mastodon { + order: 1; + flex: 0 0 auto; + max-width: 100%; + + .about-hashtag { + background: unset; + padding: 0; + border-radius: 0; + + .cta { + margin: 20px 0; + } + } + + .features-list { + display: none; + } + } + } + } + } + } +} + +@keyframes floating { + from { + transform: translate(0, 0); + } + + 65% { + transform: translate(0, 4px); + } + + to { + transform: translate(0, -0); + } +} diff --git a/app/javascript/themes/glitch/styles/accounts.scss b/app/javascript/themes/glitch/styles/accounts.scss new file mode 100644 index 000000000..2cf98c642 --- /dev/null +++ b/app/javascript/themes/glitch/styles/accounts.scss @@ -0,0 +1,589 @@ +.card { + background-color: lighten($ui-base-color, 4%); + background-size: cover; + background-position: center; + border-radius: 4px 4px 0 0; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + overflow: hidden; + position: relative; + display: flex; + + &::after { + background: rgba(darken($ui-base-color, 8%), 0.5); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } + + @media screen and (max-width: 740px) { + border-radius: 0; + box-shadow: none; + } + + .card__illustration { + padding: 60px 0; + position: relative; + flex: 1 1 auto; + display: flex; + justify-content: center; + align-items: center; + } + + .card__bio { + max-width: 260px; + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-between; + background: rgba(darken($ui-base-color, 8%), 0.8); + position: relative; + z-index: 2; + } + + &.compact { + padding: 30px 0; + border-radius: 4px; + + .avatar { + margin-bottom: 0; + + img { + object-fit: cover; + } + } + } + + .name { + display: block; + font-size: 20px; + line-height: 18px * 1.5; + color: $primary-text-color; + padding: 10px 15px; + padding-bottom: 0; + font-weight: 500; + 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; + } + } + + .avatar { + @include avatar-size(120px); + margin: 0 auto; + position: relative; + z-index: 2; + + img { + @include avatar-radius(); + @include avatar-size(120px); + display: block; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + } + } + + .controls { + position: absolute; + top: 15px; + left: 15px; + z-index: 2; + + .icon-button { + color: rgba($white, 0.8); + text-decoration: none; + font-size: 13px; + line-height: 13px; + font-weight: 500; + + .fa { + font-weight: 400; + margin-right: 5px; + } + + &:hover, + &:active, + &:focus { + color: $white; + } + } + } + + .roles { + margin-bottom: 30px; + padding: 0 15px; + } + + .details-counters { + margin-top: 30px; + display: flex; + flex-direction: row; + width: 100%; + } + + .counter { + width: 33.3%; + box-sizing: border-box; + flex: 0 0 auto; + color: $ui-primary-color; + padding: 5px 10px 0; + margin-bottom: 10px; + border-right: 1px solid lighten($ui-base-color, 4%); + cursor: default; + text-align: center; + position: relative; + + a { + display: block; + } + + &:last-child { + border-right: 0; + } + + &::after { + display: block; + content: ""; + position: absolute; + bottom: -10px; + left: 0; + width: 100%; + border-bottom: 4px solid $ui-primary-color; + opacity: 0.5; + transition: all 400ms ease; + } + + &.active { + &::after { + border-bottom: 4px solid $ui-highlight-color; + opacity: 1; + } + } + + &:hover { + &::after { + opacity: 1; + transition-duration: 100ms; + } + } + + a { + text-decoration: none; + color: inherit; + } + + .counter-label { + font-size: 12px; + display: block; + margin-bottom: 5px; + } + + .counter-number { + font-weight: 500; + font-size: 18px; + color: $primary-text-color; + font-family: 'mastodon-font-display', sans-serif; + } + } + + .bio { + font-size: 14px; + line-height: 18px; + padding: 0 15px; + color: $ui-secondary-color; + } + + .metadata { + $meta-table-border: darken($classic-highlight-color, 20%);//#174f77; + + border-collapse: collapse; + padding: 0; + margin: 15px -15px -10px -15px; + border: 0 none; + border-top: 1px solid $meta-table-border; + border-bottom: 1px solid $meta-table-border; + + td, th { + padding: 10px; + border: 0 none; + border-bottom: 1px solid $meta-table-border; + vertical-align: middle; + } + + tr:last-child { + td, th { + border-bottom: 0 none; + } + } + + td { + color: $ui-primary-color; + width:100%; // makes it stretch + padding-left: 0; + } + + th { + padding-left: 15px; + font-weight: bold; + text-align: left; + width: 94px; + color: $ui-secondary-color; + background: darken($ui-base-color, 8%); + //background: #131415; + } + + a { + color: $classic-highlight-color; + } + } + + @media screen and (max-width: 480px) { + display: block; + + .card__bio { + max-width: none; + } + + .name, + .roles { + text-align: center; + margin-bottom: 15px; + } + + .bio { + margin-bottom: 15px; + } + } +} + +.pagination { + padding: 30px 0; + text-align: center; + overflow: hidden; + + a, + .current, + .next, + .prev, + .page, + .gap { + font-size: 14px; + color: $primary-text-color; + font-weight: 500; + display: inline-block; + padding: 6px 10px; + text-decoration: none; + } + + .current { + background: $simple-background-color; + border-radius: 100px; + color: $ui-base-color; + cursor: default; + margin: 0 10px; + } + + .gap { + cursor: default; + } + + .prev, + .next { + text-transform: uppercase; + color: $ui-secondary-color; + } + + .prev { + float: left; + padding-left: 0; + + .fa { + display: inline-block; + margin-right: 5px; + } + } + + .next { + float: right; + padding-right: 0; + + .fa { + display: inline-block; + margin-left: 5px; + } + } + + .disabled { + cursor: default; + color: lighten($ui-base-color, 10%); + } + + @media screen and (max-width: 700px) { + padding: 30px 20px; + + .page { + display: none; + } + + .next, + .prev { + display: inline-block; + } + } +} + +.accounts-grid { + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + background: darken($simple-background-color, 8%); + border-radius: 0 0 4px 4px; + padding: 20px 5px; + padding-bottom: 10px; + overflow: hidden; + display: flex; + flex-wrap: wrap; + z-index: 2; + position: relative; + + @media screen and (max-width: 740px) { + border-radius: 0; + box-shadow: none; + } + + .account-grid-card { + box-sizing: border-box; + width: 335px; + background: $simple-background-color; + border-radius: 4px; + color: $ui-base-color; + margin: 0 5px 10px; + position: relative; + + @media screen and (max-width: 740px) { + width: calc(100% - 10px); + } + + .account-grid-card__header { + overflow: hidden; + height: 100px; + border-radius: 4px 4px 0 0; + background-color: lighten($ui-base-color, 4%); + background-size: cover; + background-position: center; + position: relative; + + &::after { + background: rgba(darken($ui-base-color, 8%), 0.5); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } + } + + .account-grid-card__avatar { + box-sizing: border-box; + padding: 15px; + position: absolute; + z-index: 2; + top: 100px - (40px + 2px); + left: -2px; + } + + .avatar { + @include avatar-size(80px); + + img { + display: block; + @include avatar-radius(); + @include avatar-size(80px); + border: 2px solid $simple-background-color; + background: $simple-background-color; + } + } + + .name { + padding: 15px; + padding-top: 10px; + padding-left: 15px + 80px + 15px; + + a { + display: block; + color: $ui-base-color; + text-decoration: none; + text-overflow: ellipsis; + overflow: hidden; + font-weight: 500; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + } + + .display_name { + font-size: 16px; + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + .username { + color: lighten($ui-base-color, 34%); + font-size: 14px; + font-weight: 400; + } + + .note { + padding: 10px 15px; + padding-top: 15px; + box-sizing: border-box; + color: lighten($ui-base-color, 26%); + word-wrap: break-word; + min-height: 80px; + } + } +} + +.nothing-here { + width: 100%; + display: block; + color: $ui-primary-color; + font-size: 14px; + font-weight: 500; + text-align: center; + padding: 60px 0; + padding-top: 55px; + cursor: default; +} + +.account-card { + padding: 14px 10px; + background: $simple-background-color; + border-radius: 4px; + text-align: left; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + .detailed-status__display-name { + display: block; + overflow: hidden; + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + + & > div { + @include avatar-size(48px); + float: left; + margin-right: 10px; + } + + .avatar { + @include avatar-radius(); + display: block; + } + + .display-name { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: default; + + strong { + font-weight: 500; + color: $ui-base-color; + } + + span { + font-size: 14px; + color: $ui-primary-color; + } + } + + &:hover { + .display-name { + strong { + text-decoration: none; + } + } + } + } + + .account__header__content { + font-size: 14px; + color: $ui-base-color; + } +} + +.activity-stream-tabs { + background: $simple-background-color; + border-bottom: 1px solid $ui-secondary-color; + position: relative; + z-index: 2; + + a { + display: inline-block; + padding: 15px; + text-decoration: none; + color: $ui-highlight-color; + text-transform: uppercase; + font-weight: 500; + + &:hover, + &:active, + &:focus { + color: lighten($ui-highlight-color, 8%); + } + + &.active { + color: $ui-base-color; + cursor: default; + } + } +} + +.account-role { + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: $ui-secondary-color; + background-color: rgba($ui-secondary-color, 0.1); + border: 1px solid rgba($ui-secondary-color, 0.5); + + &.moderator { + color: $success-green; + background-color: rgba($success-green, 0.1); + border-color: rgba($success-green, 0.5); + } + + &.admin { + color: lighten($error-red, 12%); + background-color: rgba(lighten($error-red, 12%), 0.1); + border-color: rgba(lighten($error-red, 12%), 0.5); + } +} diff --git a/app/javascript/themes/glitch/styles/admin.scss b/app/javascript/themes/glitch/styles/admin.scss new file mode 100644 index 000000000..87bc710af --- /dev/null +++ b/app/javascript/themes/glitch/styles/admin.scss @@ -0,0 +1,349 @@ +.admin-wrapper { + display: flex; + justify-content: center; + height: 100%; + + .sidebar-wrapper { + flex: 1; + height: 100%; + background: $ui-base-color; + display: flex; + justify-content: flex-end; + } + + .sidebar { + width: 240px; + height: 100%; + padding: 0; + overflow-y: auto; + + .logo { + display: block; + margin: 40px auto; + width: 100px; + height: 100px; + } + + ul { + list-style: none; + border-radius: 4px 0 0 4px; + overflow: hidden; + margin-bottom: 20px; + + a { + display: block; + padding: 15px; + color: rgba($primary-text-color, 0.7); + text-decoration: none; + transition: all 200ms linear; + border-radius: 4px 0 0 4px; + + i.fa { + margin-right: 5px; + } + + &:hover { + color: $primary-text-color; + background-color: darken($ui-base-color, 5%); + transition: all 100ms linear; + } + + &.selected { + background: darken($ui-base-color, 2%); + border-radius: 4px 0 0; + } + } + + ul { + background: darken($ui-base-color, 4%); + border-radius: 0 0 0 4px; + margin: 0; + + a { + border: 0; + padding: 15px 35px; + + &.selected { + color: $primary-text-color; + background-color: $ui-highlight-color; + border-bottom: 0; + border-radius: 0; + + &:hover { + background-color: lighten($ui-highlight-color, 5%); + } + } + } + } + } + } + + .content-wrapper { + flex: 2; + overflow: auto; + } + + .content { + max-width: 700px; + padding: 20px 15px; + padding-top: 60px; + padding-left: 25px; + + h2 { + color: $ui-secondary-color; + font-size: 24px; + line-height: 28px; + font-weight: 400; + margin-bottom: 40px; + } + + h3 { + color: $ui-secondary-color; + font-size: 20px; + line-height: 28px; + font-weight: 400; + margin-bottom: 30px; + } + + h6 { + font-size: 16px; + color: $ui-secondary-color; + line-height: 28px; + font-weight: 400; + } + + & > p { + font-size: 14px; + line-height: 18px; + color: $ui-secondary-color; + margin-bottom: 20px; + + strong { + color: $primary-text-color; + font-weight: 500; + } + } + + hr { + margin: 20px 0; + border: 0; + background: transparent; + border-bottom: 1px solid $ui-base-color; + } + + .muted-hint { + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + } + } + + .positive-hint { + color: $valid-value-color; + font-weight: 500; + } + } + + .simple_form { + max-width: 400px; + + &.edit_user, + &.new_form_admin_settings, + &.new_form_two_factor_confirmation, + &.new_form_delete_confirmation, + &.new_import, + &.new_domain_block, + &.edit_domain_block { + max-width: none; + } + + .form_two_factor_confirmation_code, + .form_delete_confirmation_password { + max-width: 400px; + } + + .actions { + max-width: 400px; + } + } + + @media screen and (max-width: 600px) { + display: block; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + + .sidebar-wrapper, + .content-wrapper { + flex: 0 0 auto; + height: auto; + overflow: initial; + } + + .sidebar { + width: 100%; + padding: 10px 0; + height: auto; + + .logo { + margin: 20px auto; + } + } + + .content { + padding-top: 20px; + } + } +} + +.filters { + display: flex; + flex-wrap: wrap; + + .filter-subset { + flex: 0 0 auto; + margin: 0 40px 10px 0; + + &:last-child { + margin-bottom: 20px; + } + + ul { + margin-top: 5px; + list-style: none; + + li { + display: inline-block; + margin-right: 5px; + } + } + + strong { + font-weight: 500; + text-transform: uppercase; + font-size: 12px; + } + + a { + display: inline-block; + color: rgba($primary-text-color, 0.7); + text-decoration: none; + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + border-bottom: 2px solid $ui-base-color; + + &:hover { + color: $primary-text-color; + border-bottom: 2px solid lighten($ui-base-color, 5%); + } + + &.selected { + color: $ui-highlight-color; + border-bottom: 2px solid $ui-highlight-color; + } + } + } +} + +.report-accounts { + display: flex; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.report-accounts__item { + display: flex; + flex: 250px; + flex-direction: column; + margin: 0 5px; + + & > strong { + display: block; + margin: 0 0 10px -5px; + font-weight: 500; + font-size: 14px; + line-height: 18px; + color: $ui-secondary-color; + } + + .account-card { + flex: 1 1 auto; + } +} + +.report-status, +.account-status { + display: flex; + margin-bottom: 10px; + + .activity-stream { + flex: 2 0 0; + margin-right: 20px; + max-width: calc(100% - 60px); + + .entry { + border-radius: 4px; + } + } +} + +.report-status__actions, +.account-status__actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + + .icon-button { + font-size: 24px; + width: 24px; + text-align: center; + margin-bottom: 10px; + } +} + +.batch-form-box { + display: flex; + flex-wrap: wrap; + margin-bottom: 5px; + + #form_status_batch_action { + margin: 0 5px 5px 0; + font-size: 14px; + } + + input.button { + margin: 0 5px 5px 0; + } + + .media-spoiler-toggle-buttons { + margin-left: auto; + + .button { + overflow: visible; + margin: 0 0 5px 5px; + float: right; + } + } +} + +.batch-checkbox, +.batch-checkbox-all { + display: flex; + align-items: center; + margin-right: 5px; +} + +.back-link { + margin-bottom: 10px; + font-size: 14px; + + a { + color: $classic-highlight-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/app/javascript/themes/glitch/styles/basics.scss b/app/javascript/themes/glitch/styles/basics.scss new file mode 100644 index 000000000..b5d77ff63 --- /dev/null +++ b/app/javascript/themes/glitch/styles/basics.scss @@ -0,0 +1,122 @@ +body { + font-family: 'mastodon-font-sans-serif', sans-serif; + background: $ui-base-color; + background-size: cover; + background-attachment: fixed; + font-size: 13px; + line-height: 18px; + font-weight: 400; + color: $primary-text-color; + padding-bottom: 20px; + text-rendering: optimizelegibility; + font-feature-settings: "kern"; + text-size-adjust: none; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: transparent; + + &.system-font { + // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+) + // -apple-system => Safari <11 specific + // BlinkMacSystemFont => Chrome <56 on macOS specific + // Segoe UI => Windows 7/8/10 + // Oxygen => KDE + // Ubuntu => Unity/Ubuntu + // Cantarell => GNOME + // Fira Sans => Firefox OS + // Droid Sans => Older Androids (<4.0) + // Helvetica Neue => Older macOS <10.11 + // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0) + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif; + } + + &.app-body { + position: absolute; + width: 100%; + height: 100%; + padding: 0; + background: $ui-base-color; + } + + &.about-body { + background: darken($ui-base-color, 8%); + padding-bottom: 0; + } + + &.tag-body { + background: darken($ui-base-color, 8%); + padding-bottom: 0; + } + + &.embed { + background: transparent; + margin: 0; + padding-bottom: 0; + + .container { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + } + } + + &.admin { + background: darken($ui-base-color, 4%); + position: fixed; + width: 100%; + height: 100%; + padding: 0; + } + + &.error { + position: absolute; + text-align: center; + color: $ui-primary-color; + background: $ui-base-color; + width: 100%; + height: 100%; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + + .dialog { + vertical-align: middle; + margin: 20px; + + img { + display: block; + max-width: 470px; + width: 100%; + height: auto; + margin-top: -120px; + } + + h1 { + font-size: 20px; + line-height: 28px; + font-weight: 400; + } + } + } +} + +button { + font-family: inherit; + cursor: pointer; + + &:focus { + outline: none; + } +} + +.app-holder { + &, + & > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + } +} diff --git a/app/javascript/themes/glitch/styles/boost.scss b/app/javascript/themes/glitch/styles/boost.scss new file mode 100644 index 000000000..b07b72f8e --- /dev/null +++ b/app/javascript/themes/glitch/styles/boost.scss @@ -0,0 +1,28 @@ +@function hex-color($color) { + @if type-of($color) == 'color' { + $color: str-slice(ie-hex-str($color), 4); + } + @return '%23' + unquote($color) +} + +button.icon-button i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-base-lighter-color)}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>"); + + &:hover { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 33%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>"); + } +} + +// Disabled variant +button.icon-button.disabled i.fa-retweet { + &, &:hover { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 13%))}' stroke-width='0'/></svg>"); + } +} + +// Disabled variant for use with DMs +.status-direct button.icon-button.disabled i.fa-retweet { + &, &:hover { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 16%))}' stroke-width='0'/></svg>"); + } +} diff --git a/app/javascript/themes/glitch/styles/compact_header.scss b/app/javascript/themes/glitch/styles/compact_header.scss new file mode 100644 index 000000000..90d98cc8c --- /dev/null +++ b/app/javascript/themes/glitch/styles/compact_header.scss @@ -0,0 +1,34 @@ +.compact-header { + h1 { + font-size: 24px; + line-height: 28px; + color: $ui-primary-color; + font-weight: 500; + margin-bottom: 20px; + padding: 0 10px; + word-wrap: break-word; + + @media screen and (max-width: 740px) { + text-align: center; + padding: 20px 10px 0; + } + + a { + color: inherit; + text-decoration: none; + } + + small { + font-weight: 400; + color: $ui-secondary-color; + } + + img { + display: inline-block; + margin-bottom: -5px; + margin-right: 15px; + width: 36px; + height: 36px; + } + } +} diff --git a/app/javascript/themes/glitch/styles/components.scss b/app/javascript/themes/glitch/styles/components.scss new file mode 100644 index 000000000..3be8db4b4 --- /dev/null +++ b/app/javascript/themes/glitch/styles/components.scss @@ -0,0 +1,4832 @@ +@import 'variables'; + +.app-body { + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.button { + background-color: darken($ui-highlight-color, 3%); + border: 10px none; + border-radius: 4px; + box-sizing: border-box; + color: $primary-text-color; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: 14px; + font-weight: 500; + height: 36px; + letter-spacing: 0; + line-height: 36px; + overflow: hidden; + padding: 0 16px; + position: relative; + text-align: center; + text-transform: uppercase; + text-decoration: none; + text-overflow: ellipsis; + transition: all 100ms ease-in; + white-space: nowrap; + width: auto; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-highlight-color, 7%); + transition: all 200ms ease-out; + } + + &:disabled { + background-color: $ui-primary-color; + cursor: default; + } + + &.button-alternative { + font-size: 16px; + line-height: 36px; + height: auto; + color: $ui-base-color; + background: $ui-primary-color; + text-transform: none; + padding: 4px 16px; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-primary-color, 4%); + } + } + + &.button-secondary { + font-size: 16px; + line-height: 36px; + height: auto; + color: $ui-primary-color; + text-transform: none; + background: transparent; + padding: 3px 15px; + border-radius: 4px; + border: 1px solid $ui-primary-color; + + &:active, + &:focus, + &:hover { + border-color: lighten($ui-primary-color, 4%); + color: lighten($ui-primary-color, 4%); + } + } + + &.button--block { + display: block; + width: 100%; + } +} + +.column__wrapper { + display: flex; + flex: 1 1 auto; + position: relative; +} + +.column-icon { + background: lighten($ui-base-color, 4%); + color: $ui-primary-color; + cursor: pointer; + font-size: 16px; + padding: 15px; + position: absolute; + right: 0; + top: -48px; + z-index: 3; + + &:hover { + color: lighten($ui-primary-color, 7%); + } +} + +.icon-button { + display: inline-block; + padding: 0; + color: $ui-base-lighter-color; + border: none; + background: transparent; + cursor: pointer; + transition: color 100ms ease-in; + + &:hover, + &:active, + &:focus { + color: lighten($ui-base-color, 33%); + transition: color 200ms ease-out; + } + + &.disabled { + color: lighten($ui-base-color, 13%); + cursor: default; + } + + &.active { + color: $ui-highlight-color; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &.inverted { + color: lighten($ui-base-color, 33%); + + &:hover, + &:active, + &:focus { + color: $ui-base-lighter-color; + } + + &.disabled { + color: $ui-primary-color; + } + + &.active { + color: $ui-highlight-color; + + &.disabled { + color: lighten($ui-highlight-color, 13%); + } + } + } + + &.overlayed { + box-sizing: content-box; + background: rgba($base-overlay-background, 0.6); + color: rgba($primary-text-color, 0.7); + border-radius: 4px; + padding: 2px; + + &:hover { + background: rgba($base-overlay-background, 0.9); + } + } +} + +.text-icon-button { + color: lighten($ui-base-color, 33%); + border: none; + background: transparent; + cursor: pointer; + font-weight: 600; + font-size: 11px; + padding: 0 3px; + line-height: 27px; + outline: 0; + transition: color 100ms ease-in; + + &:hover, + &:active, + &:focus { + color: $ui-base-lighter-color; + transition: color 200ms ease-out; + } + + &.disabled { + color: lighten($ui-base-color, 13%); + cursor: default; + } + + &.active { + color: $ui-highlight-color; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } +} + +.dropdown-menu { + position: absolute; +} + +.dropdown--active .icon-button { + color: $ui-highlight-color; +} + +.dropdown--active::after { + @media screen and (min-width: 631px) { + content: ""; + display: block; + position: absolute; + width: 0; + height: 0; + border-style: solid; + border-width: 0 4.5px 7.8px; + border-color: transparent transparent $ui-secondary-color; + bottom: 8px; + right: 104px; + } +} + +.invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; + height: 0; + position: absolute; + + img, + svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; + } +} + +.ellipsis { + &::after { + content: "…"; + } +} + +.lightbox .icon-button { + color: $ui-base-color; +} + +.compose-form { + padding: 10px; +} + +.compose-form__warning { + color: darken($ui-secondary-color, 65%); + margin-bottom: 15px; + background: $ui-primary-color; + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3); + padding: 8px 10px; + border-radius: 4px; + font-size: 13px; + font-weight: 400; + + strong { + color: darken($ui-secondary-color, 65%); + font-weight: 500; + } + + a { + color: darken($ui-primary-color, 33%); + font-weight: 500; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } +} + +.compose-form__modifiers { + color: $ui-base-color; + font-family: inherit; + font-size: 14px; + background: $simple-background-color; +} + +.compose-form__buttons-wrapper { + display: flex; + justify-content: space-between; +} + +.compose-form__buttons { + padding: 10px; + background: darken($simple-background-color, 8%); + box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05); + border-radius: 0 0 4px 4px; + display: flex; + + .icon-button { + box-sizing: content-box; + padding: 0 3px; + } +} + +.compose-form__buttons-separator { + border-left: 1px solid #c3c3c3; + margin: 0 3px; +} + +.compose-form__upload-button-icon { + line-height: 27px; +} + +.compose-form__sensitive-button { + display: none; + + &.compose-form__sensitive-button--visible { + display: block; + } + + .compose-form__sensitive-button__icon { + line-height: 27px; + } +} + +.compose-form__upload-wrapper { + overflow: hidden; +} + +.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 { + border-radius: 4px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + height: 100px; + width: 100%; +} + +.compose-form__label { + display: block; + line-height: 24px; + vertical-align: middle; + + &.with-border { + border-top: 1px solid $ui-base-color; + padding-top: 10px; + } + + .compose-form__label__text { + display: inline-block; + vertical-align: middle; + margin-bottom: 14px; + margin-left: 8px; + color: $ui-primary-color; + } +} + +.compose-form__textarea, +.follow-form__input { + background: $simple-background-color; + + &:disabled { + background: $ui-secondary-color; + } +} + +.compose-form__autosuggest-wrapper { + position: relative; + + .emoji-picker-dropdown { + position: absolute; + right: 5px; + top: 5px; + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } + } +} + +.compose-form__publish { + display: flex; + justify-content: flex-end; + min-width: 0; +} + +.compose-form__publish-button-wrapper { + overflow: hidden; + padding-top: 10px; + white-space: nowrap; + display: flex; + + button { + text-overflow: unset; + } +} + +.compose-form__publish__side-arm { + padding: 0 !important; + width: 36px; + text-align: center; + margin-right: 2px; +} + +.compose-form__publish__primary { + padding: 0 10px !important; +} + +.emojione { + display: inline-block; + font-size: inherit; + vertical-align: middle; + object-fit: contain; + margin: -.2ex .15em .2ex; + width: 16px; + height: 16px; + + img { + width: auto; + } +} + +.reply-indicator { + border-radius: 4px 4px 0 0; + position: relative; + bottom: -2px; + background: $ui-primary-color; + padding: 10px; +} + +.reply-indicator__header { + margin-bottom: 5px; + overflow: hidden; +} + +.reply-indicator__cancel { + float: right; + line-height: 24px; +} + +.reply-indicator__display-name { + color: $ui-base-color; + display: block; + max-width: 100%; + line-height: 24px; + overflow: hidden; + padding-right: 25px; + text-decoration: none; +} + +.reply-indicator__display-avatar { + float: left; + margin-right: 5px; +} + +.status__content--with-action { + cursor: pointer; +} + +.status-check-box { + .status__content, + .reply-indicator__content { + color: #3a3a3a; + a { + color: #005aa9; + } + } +} + +.status__content, +.reply-indicator__content { + position: relative; + margin: 10px 0; + padding: 0 12px; + font-size: 15px; + line-height: 20px; + color: $primary-text-color; + word-wrap: break-word; + font-weight: 400; + overflow: visible; + white-space: pre-wrap; + padding-top: 5px; + + &.status__content--with-spoiler { + white-space: normal; + + .status__content__text { + white-space: pre-wrap; + } + } + + .emojione { + width: 20px; + height: 20px; + margin: -5px 0 0; + } + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $ui-secondary-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + + .fa { + color: lighten($ui-base-color, 40%); + } + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + .fa { + color: lighten($ui-base-color, 30%); + } + } + + .status__content__spoiler { + display: none; + + &.status__content__spoiler--visible { + display: block; + } + } +} + +.status__content__spoiler-link { + display: inline-block; + border-radius: 2px; + background: lighten($ui-base-color, 30%); + border: none; + color: lighten($ui-base-color, 8%); + font-weight: 500; + font-size: 11px; + padding: 0 5px; + text-transform: uppercase; + line-height: inherit; + cursor: pointer; + vertical-align: bottom; + + &:hover { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } + + .status__content__spoiler-icon { + display: inline-block; + margin: 0 0 0 5px; + border-left: 1px solid currentColor; + padding: 0 0 0 4px; + font-size: 16px; + vertical-align: -2px; + } +} + +.status__prepend-icon-wrapper { + float: left; + margin: 0 10px 0 -58px; + width: 48px; + text-align: right; +} + +.notif-cleaning { + .status, .notification-follow { + padding-right: ($dismiss-overlay-width + 0.5rem); + } +} + +.notification-follow { + position: relative; + + // same like Status + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .account { + border-bottom: 0 none; + } +} + +.focusable { + &:focus { + outline: 0; + background: lighten($ui-base-color, 4%); + + .status.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; + height: auto; + min-height: 48px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: default; + + @supports (-ms-overflow-style: -ms-autohiding-scrollbar) { + // Add margin to avoid Edge auto-hiding scrollbar appearing over content. + // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px. + padding-right: 26px; // 10px + 16px + } + + @keyframes fade { + 0% { opacity: 0; } + 100% { opacity: 1; } + } + + opacity: 1; + animation: fade 150ms linear; + + .video-player { + margin-top: 8px; + } + + &.status-direct { + background: lighten($ui-base-color, 8%); + + .icon-button.disabled { + color: lighten($ui-base-color, 16%); + } + } + + &.light { + .status__relative-time { + color: $ui-primary-color; + } + + .status__display-name { + color: $ui-base-color; + } + + .display-name { + strong { + color: $ui-base-color; + } + + span { + color: $ui-primary-color; + } + } + + .status__content { + color: $ui-base-color; + + a { + color: $ui-highlight-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + } + + &.collapsed { + background-position: center; + background-size: cover; + user-select: none; + + &.has-background::before { + display: block; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8)); + content: ""; + } + + .display-name:hover .display-name__html { + text-decoration: none; + } + + .status__content { + height: 20px; + overflow: hidden; + text-overflow: ellipsis; + + a:hover { + text-decoration: none; + } + } + } + + .notification__message { + margin: -10px -10px 10px; + } +} + +.notification-favourite { + .status.status-direct { + background: transparent; + + .icon-button.disabled { + color: lighten($ui-base-color, 13%); + } + } +} + +.status__relative-time { + display: inline-block; + margin-left: auto; + padding-left: 18px; + width: 120px; + color: $ui-base-lighter-color; + font-size: 14px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status__display-name { + margin: 0 auto 0 0; + color: $ui-base-lighter-color; + overflow: hidden; +} + +.status__info { + display: flex; + margin: 2px 0 5px; + font-size: 15px; + line-height: 24px; +} + +.status__info__icons { + flex: none; + position: relative; + color: lighten($ui-base-color, 26%); + + .status__visibility-icon { + padding-left: 6px; + } +} + +.status-check-box { + border-bottom: 1px solid $ui-secondary-color; + display: flex; + + .status__content { + flex: 1 1 auto; + padding: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.status-check-box-toggle { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: center; + padding: 10px; +} + +.status__prepend { + margin: -10px -10px 10px; + color: $ui-base-lighter-color; + padding: 8px 10px 0 68px; + font-size: 14px; + position: relative; + + .status__display-name strong { + color: $ui-base-lighter-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.status__action-bar { + align-items: center; + display: flex; + margin: 10px 4px 0; +} + +.status__action-bar-button { + float: left; + margin-right: 18px; + flex: 0 0 auto; +} + +.status__action-bar-dropdown { + float: left; + height: 23.15px; + width: 23.15px; + + // Dropdown style override for centering on the icon + .dropdown--active { + position: relative; + + .dropdown__content.dropdown__right { + left: calc(50% + 3px); + right: initial; + transform: translate(-50%, 0); + top: 22px; + } + + &::after { + right: 1px; + bottom: -2px; + } + } +} + +.detailed-status__action-bar-dropdown { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.detailed-status { + background: lighten($ui-base-color, 4%); + padding: 14px 10px; + + .status__content { + font-size: 19px; + line-height: 24px; + + .emojione { + width: 24px; + height: 24px; + margin: -5px 0 0; + } + } + + .video-player { + margin-top: 8px; + } +} + +.detailed-status__meta { + margin-top: 15px; + color: $ui-base-lighter-color; + font-size: 14px; + line-height: 18px; +} + +.detailed-status__action-bar { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; +} + +.detailed-status__link { + color: inherit; + text-decoration: none; +} + +.detailed-status__favorites, +.detailed-status__reblogs { + display: inline-block; + font-weight: 500; + font-size: 12px; + margin-left: 6px; +} + +.reply-indicator__content { + color: $ui-base-color; + font-size: 14px; + + a { + color: lighten($ui-base-color, 20%); + } +} + +.account { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .account__display-name { + flex: 1 1 auto; + display: block; + color: $ui-primary-color; + overflow: hidden; + text-decoration: none; + font-size: 14px; + } +} + +.account__wrapper { + display: flex; +} + +.account__avatar-wrapper { + float: left; + margin: 6px 16px 6px 6px; +} + +.account__avatar { + @include avatar-radius(); + position: relative; + cursor: pointer; + + &-inline { + display: inline-block; + vertical-align: middle; + margin-right: 5px; + } +} + +.account__avatar-overlay { + position: relative; + @include avatar-size(48px); + + &-base { + @include avatar-radius(); + @include avatar-size(36px); + } + + &-overlay { + @include avatar-radius(); + @include avatar-size(24px); + + position: absolute; + bottom: 0; + right: 0; + z-index: 1; + } +} + +.account__relationship { + height: 18px; + padding: 12px 10px; + white-space: nowrap; +} + +.account__header__wrapper { + flex: 0 0 auto; + background: lighten($ui-base-color, 4%); +} + +.account__header { + text-align: center; + background-size: cover; + background-position: center; + position: relative; + + & > div { + background: rgba(lighten($ui-base-color, 4%), 0.9); + padding: 20px 10px; + } + + .account__header__content { + color: $ui-secondary-color; + } + + .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 { + color: $ui-highlight-color; + font-size: 14px; + font-weight: 400; + display: block; + margin-bottom: 10px; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.account__disclaimer { + padding: 10px; + border-top: 1px solid lighten($ui-base-color, 8%); + color: $ui-base-lighter-color; + + strong { + font-weight: 500; + } + + a { + font-weight: 500; + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } +} + +.account__header__content { + color: $ui-primary-color; + font-size: 14px; + font-weight: 400; + overflow: hidden; + word-break: normal; + word-wrap: break-word; + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} + +.account__header__display-name { + .emojione { + width: 25px; + height: 25px; + } +} + +.account__metadata { + width: 100%; + font-size: 15px; + line-height: 20px; + overflow: hidden; + border-collapse: collapse; + + a { + text-decoration: none; + + &:hover{ + text-decoration: underline; + } + } + + tr { + border-top: 1px solid lighten($ui-base-color, 8%); + } + + th, td { + padding: 14px 20px; + vertical-align: middle; + + & > div { + max-height: 40px; + overflow-y: auto; + white-space: pre-wrap; + text-overflow: ellipsis; + } + } + + th { + color: $ui-primary-color; + background: lighten($ui-base-color, 13%); + font-variant: small-caps; + max-width: 120px; + + a { + color: $primary-text-color; + } + } + + td { + flex: auto; + color: $primary-text-color; + background: $ui-base-color; + + a { + color: $ui-highlight-color; + } + } +} + +.account__action-bar { + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + line-height: 36px; + overflow: hidden; + flex: 0 0 auto; + display: flex; +} + +.account__action-bar-dropdown { + flex: 0 1 calc(50% - 140px); + padding: 10px; + + .dropdown--active { + .dropdown__content.dropdown__right { + left: 6px; + right: initial; + } + + &::after { + bottom: initial; + margin-left: 11px; + margin-top: -7px; + right: initial; + } + } +} + +.account__action-bar-links { + display: flex; + flex: 1 1 auto; + line-height: 18px; +} + +.account__action-bar__tab { + text-decoration: none; + overflow: hidden; + flex: 0 1 80px; + border-left: 1px solid lighten($ui-base-color, 8%); + padding: 10px 5px; + + & > span { + display: block; + text-transform: uppercase; + font-size: 11px; + color: $ui-primary-color; + } + + strong { + display: block; + font-size: 15px; + font-weight: 500; + color: $primary-text-color; + } + + abbr { + color: $ui-base-lighter-color; + } +} + +.account__header__avatar { + @include avatar-radius(); + @include avatar-size(90px); + display: block; + margin: 0 auto 10px; + overflow: hidden; +} + +.account-authorize { + padding: 14px 10px; + + .detailed-status__display-name { + display: block; + margin-bottom: 15px; + overflow: hidden; + } +} + +.account-authorize__avatar { + float: left; + margin-right: 10px; +} + +.status__display-name, +.status__relative-time, +.detailed-status__display-name, +.detailed-status__datetime, +.detailed-status__application, +.account__display-name { + text-decoration: none; +} + +.status__display-name, +.account__display-name { + strong { + color: $primary-text-color; + } +} + +.muted { + .emojione { + opacity: 0.5; + } +} + +.account__display-name strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.detailed-status__application, +.detailed-status__datetime { + color: inherit; +} + +.detailed-status__display-name { + color: $ui-secondary-color; + display: block; + line-height: 24px; + margin-bottom: 15px; + overflow: hidden; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + strong { + font-size: 16px; + color: $primary-text-color; + } +} + +.detailed-status__display-avatar { + float: left; + margin-right: 10px; +} + +.status__avatar { + flex: none; + margin: 0 10px 0 0; + height: 48px; + width: 48px; +} + +.muted { + .status__content p, + .status__content a { + color: $ui-base-lighter-color; + } + + .status__display-name strong { + color: $ui-base-lighter-color; + } + + .status__avatar, .emojione { + opacity: 0.5; + } + + a.status__content__spoiler-link { + background: $ui-base-lighter-color; + color: lighten($ui-base-color, 4%); + + &:hover { + background: lighten($ui-base-color, 29%); + text-decoration: none; + } + } +} + +.notification__message { + padding: 8px 10px 0 68px; + cursor: default; + color: $ui-primary-color; + font-size: 15px; + position: relative; + + .fa { + color: $ui-highlight-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.notification__favourite-icon-wrapper { + float: left; + margin: 0 10px 0 -58px; + width: 48px; + text-align: right; + + .star-icon { + color: $gold-star; + } +} + +.star-icon.active { + color: $gold-star; +} + +.notification__display-name { + color: inherit; + font-weight: 500; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } +} + +.display-name { + display: block; + padding: 6px 0; + max-width: 100%; + height: 36px; + overflow: hidden; + + strong { + display: block; + height: 18px; + font-size: 16px; + font-weight: 500; + line-height: 18px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + span { + display: block; + height: 18px; + font-size: 15px; + line-height: 18px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &:hover { + strong { + text-decoration: underline; + } + } +} + +.status__relative-time, +.detailed-status__datetime { + &:hover { + text-decoration: underline; + } +} + +.image-loader { + position: relative; + + &.image-loader--loading { + .image-loader__preview-canvas { + filter: blur(2px); + } + } + + .image-loader__img { + position: absolute; + top: 0; + left: 0; + right: 0; + max-width: 100%; + max-height: 100%; + background-image: none; + } + + &.image-loader--amorphous { + position: static; + + .image-loader__preview-canvas { + display: none; + } + + .image-loader__img { + position: static; + width: auto; + height: auto; + } + } +} + +.navigation-bar { + padding: 10px; + display: flex; + flex-shrink: 0; + cursor: default; + color: $ui-primary-color; + + strong { + color: $primary-text-color; + } + + .permalink { + text-decoration: none; + } + + .icon-button { + pointer-events: none; + opacity: 0; + } +} + +.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 { + color: inherit; + text-decoration: none; +} + +.dropdown { + display: inline-block; +} + +.dropdown__content { + display: none; + position: absolute; +} + +.dropdown-menu__separator { + border-bottom: 1px solid darken($ui-secondary-color, 8%); + margin: 5px 7px 6px; + height: 0; +} + +.dropdown-menu { + background: $ui-secondary-color; + padding: 4px 0; + border-radius: 4px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + ul { + list-style: none; + } +} + +.dropdown-menu__arrow { + position: absolute; + width: 0; + height: 0; + border: 0 solid transparent; + + &.left { + right: -5px; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: $ui-secondary-color; + } + + &.top { + bottom: -5px; + margin-left: -13px; + border-width: 5px 7px 0; + border-top-color: $ui-secondary-color; + } + + &.bottom { + top: -5px; + margin-left: -13px; + border-width: 0 7px 5px; + border-bottom-color: $ui-secondary-color; + } + + &.right { + left: -5px; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: $ui-secondary-color; + } +} + +.dropdown-menu__item { + a { + font-size: 13px; + line-height: 18px; + display: block; + padding: 4px 14px; + box-sizing: border-box; + text-decoration: none; + background: $ui-secondary-color; + color: $ui-base-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus, + &:hover, + &:active { + background: $ui-highlight-color; + color: $ui-secondary-color; + outline: 0; + } + } +} + +.dropdown--active .dropdown__content { + display: block; + line-height: 18px; + max-width: 311px; + right: 0; + text-align: left; + z-index: 9999; + + & > ul { + list-style: none; + background: $ui-secondary-color; + padding: 4px 0; + border-radius: 4px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.4); + min-width: 140px; + position: relative; + } + + &.dropdown__right { + right: 0; + } + + &.dropdown__left { + & > ul { + left: -98px; + } + } + + & > ul > li > a { + font-size: 13px; + line-height: 18px; + display: block; + padding: 4px 14px; + box-sizing: border-box; + text-decoration: none; + background: $ui-secondary-color; + color: $ui-base-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + outline: 0; + } + + &:hover { + background: $ui-highlight-color; + color: $ui-secondary-color; + } + } +} + +.dropdown__icon { + vertical-align: middle; +} + +.static-content { + padding: 10px; + padding-top: 20px; + color: $ui-base-lighter-color; + + h1 { + font-size: 16px; + font-weight: 500; + margin-bottom: 40px; + text-align: center; + } + + p { + font-size: 13px; + margin-bottom: 20px; + } +} + +.columns-area { + display: flex; + flex: 1 1 auto; + flex-direction: row; + justify-content: flex-start; + overflow-x: auto; + position: relative; + padding: 10px; +} + +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { + .columns-area { + padding: 0; + } + + .react-swipeable-view-container .columns-area { + height: calc(100% - 20px) !important; + } +} + +.react-swipeable-view-container { + &, + .columns-area, + .drawer, + .column { + height: 100%; + } +} + +.react-swipeable-view-container > * { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.column { + width: 330px; + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; + + .wide & { + flex: auto; + min-width: 330px; + max-width: 400px; + } + + > .scrollable { + background: $ui-base-color; + } +} + +.ui { + flex: 0 0 auto; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: darken($ui-base-color, 7%); +} + +.drawer { + width: 300px; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow-y: auto; + + .wide & { + flex: 1 1 200px; + min-width: 300px; + max-width: 400px; + } +} + +.drawer__tab { + display: block; + flex: 1 1 auto; + padding: 15px 5px 13px; + color: $ui-primary-color; + text-decoration: none; + text-align: center; + font-size: 16px; + border-bottom: 2px solid transparent; + outline: none; + cursor: pointer; +} + +.column, +.drawer { + flex: 1 1 100%; + overflow: hidden; +} + +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { + .tabs-bar { + margin: 0; + } + + .search { + margin-bottom: 0; + } +} + +:root { // Overrides .wide stylings for mobile view + @include single-column('screen and (max-width: 630px)', $parent: null) { + .column, + .drawer { + flex: auto; + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + } + + .columns-area { + flex-direction: column; + } + + .search__input, + .autosuggest-textarea__textarea { + font-size: 16px; + } + } +} + +@include multi-columns('screen and (min-width: 631px)', $parent: null) { + .columns-area { + padding: 0; + } + + .column, + .drawer { + padding: 10px; + padding-left: 5px; + padding-right: 5px; + + &:first-child { + padding-left: 10px; + } + + &:last-child { + padding-right: 10px; + } + } + + .columns-area > div { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + } + } +} + +.drawer__pager { + box-sizing: border-box; + padding: 0; + flex: 1 1 auto; + position: relative; +} + +.drawer__inner { + background: lighten($ui-base-color, 13%); + box-sizing: border-box; + padding: 0; + position: absolute; + height: 100%; + width: 100%; + + &.darker { + position: absolute; + top: 0; + left: 0; + background: $ui-base-color; + width: 100%; + height: 100%; + } +} + +.pseudo-drawer { + background: lighten($ui-base-color, 13%); + font-size: 13px; + text-align: left; +} + +.drawer__header { + flex: 0 0 auto; + font-size: 16px; + background: lighten($ui-base-color, 8%); + margin-bottom: 10px; + display: flex; + flex-direction: row; + + a { + transition: background 100ms ease-in; + + &:hover { + background: lighten($ui-base-color, 3%); + transition: background 200ms ease-out; + } + } +} + +.tabs-bar { + display: flex; + background: lighten($ui-base-color, 8%); + flex: 0 0 auto; + overflow-y: auto; + margin: 10px; + margin-bottom: 0; +} + +.tabs-bar__link { + display: block; + flex: 1 1 auto; + padding: 15px 10px; + color: $primary-text-color; + text-decoration: none; + text-align: center; + font-size: 14px; + font-weight: 500; + border-bottom: 2px solid lighten($ui-base-color, 8%); + transition: all 200ms linear; + + .fa { + font-weight: 400; + font-size: 16px; + } + + &.active { + border-bottom: 2px solid $ui-highlight-color; + color: $ui-highlight-color; + } + + &:hover, + &:focus, + &:active { + @include multi-columns('screen and (min-width: 631px)') { + background: lighten($ui-base-color, 14%); + transition: all 100ms linear; + } + } + + span { + margin-left: 5px; + display: none; + } +} + +@include limited-single-column('screen and (max-width: 600px)', $parent: null) { + .tabs-bar__link { + span { + display: inline; + } + } +} + +@include multi-columns('screen and (min-width: 631px)', $parent: null) { + .tabs-bar { + display: none; + } +} + +.scrollable { + overflow-y: scroll; + overflow-x: hidden; + flex: 1 1 auto; + -webkit-overflow-scrolling: touch; + will-change: transform; // improves perf in mobile Chrome + + &.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 { + background: lighten($ui-base-color, 4%); + color: $ui-highlight-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + border: 0; + text-align: unset; + padding: 15px; + margin: 0; + z-index: 3; + + &:hover { + text-decoration: underline; + } +} + +.column-header__back-button { + background: lighten($ui-base-color, 4%); + border: 0; + font-family: inherit; + color: $ui-highlight-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 0 5px 0 0; + z-index: 3; + + &:hover { + text-decoration: underline; + } + + &:last-child { + padding: 0 15px 0 0; + } +} + +.column-back-button__icon { + display: inline-block; + margin-right: 5px; +} + +.column-back-button--slim { + position: relative; +} + +.column-back-button--slim-button { + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 15px; + position: absolute; + right: 0; + top: -48px; +} + +.react-toggle { + display: inline-block; + position: relative; + cursor: pointer; + background-color: transparent; + border: 0; + padding: 0; + user-select: none; + -webkit-tap-highlight-color: rgba($base-overlay-background, 0); + -webkit-tap-highlight-color: transparent; +} + +.react-toggle-screenreader-only { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.react-toggle--disabled { + cursor: not-allowed; + opacity: 0.5; + transition: opacity 0.25s; +} + +.react-toggle-track { + width: 50px; + height: 24px; + padding: 0; + border-radius: 30px; + background-color: $ui-base-color; + transition: all 0.2s ease; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: darken($ui-base-color, 10%); +} + +.react-toggle--checked .react-toggle-track { + background-color: $ui-highlight-color; +} + +.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: lighten($ui-highlight-color, 10%); +} + +.react-toggle-track-check { + position: absolute; + width: 14px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + left: 8px; + opacity: 0; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-check { + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle-track-x { + position: absolute; + width: 10px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + right: 10px; + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-x { + opacity: 0; +} + +.react-toggle-thumb { + transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; + position: absolute; + top: 1px; + left: 1px; + width: 22px; + height: 22px; + border: 1px solid $ui-base-color; + border-radius: 50%; + background-color: darken($simple-background-color, 2%); + box-sizing: border-box; + transition: all 0.25s ease; +} + +.react-toggle--checked .react-toggle-thumb { + left: 27px; + border-color: $ui-highlight-color; +} + +.column-link { + background: lighten($ui-base-color, 8%); + color: $primary-text-color; + display: block; + font-size: 16px; + padding: 15px; + text-decoration: none; + cursor: pointer; + outline: none; + + &:hover { + background: lighten($ui-base-color, 11%); + } +} + +.column-link__icon { + display: inline-block; + margin-right: 5px; +} + +.column-subheading { + background: $ui-base-color; + color: $ui-base-lighter-color; + padding: 8px 20px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + cursor: default; +} + +.autosuggest-textarea, +.spoiler-input { + position: relative; +} + +.autosuggest-textarea__textarea, +.spoiler-input__input { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + color: $ui-base-color; + background: $simple-background-color; + padding: 10px; + font-family: inherit; + font-size: 14px; + resize: vertical; + border: 0; + outline: 0; + + &:focus { + outline: 0; + } + + @include limited-single-column('screen and (max-width: 600px)') { + font-size: 16px; + } +} + +.spoiler-input__input { + border-radius: 4px; +} + +.autosuggest-textarea__textarea { + min-height: 100px; + border-radius: 4px 4px 0 0; + padding-bottom: 0; + padding-right: 10px + 22px; + resize: none; + + @include limited-single-column('screen and (max-width: 600px)') { + height: 100px !important; // prevent auto-resize textarea + resize: vertical; + } +} + +.autosuggest-textarea__suggestions { + box-sizing: border-box; + display: none; + position: absolute; + top: 100%; + width: 100%; + z-index: 99; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + background: $ui-secondary-color; + border-radius: 0 0 4px 4px; + color: $ui-base-color; + font-size: 14px; + padding: 6px; + + &.autosuggest-textarea__suggestions--visible { + display: block; + } +} + +.autosuggest-textarea__suggestions__item { + padding: 10px; + cursor: pointer; + border-radius: 4px; + + &:hover, + &:focus, + &:active, + &.selected { + background: darken($ui-secondary-color, 10%); + } +} + +.autosuggest-account, +.autosuggest-emoji { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + line-height: 18px; + font-size: 14px; +} + +.autosuggest-account-icon, +.autosuggest-emoji img { + display: block; + margin-right: 8px; + width: 16px; + height: 16px; +} + +.autosuggest-account .display-name__account { + color: lighten($ui-base-color, 36%); +} + +.character-counter__wrapper { + line-height: 36px; + margin: 0 16px 0 8px; + padding-top: 10px; +} + +.character-counter { + cursor: default; + font-size: 16px; +} + +.character-counter--over { + color: $warning-red; +} + +.getting-started__wrapper { + position: relative; + overflow-y: auto; +} + +.getting-started__footer { + display: flex; + flex-direction: column; +} + +.getting-started { + box-sizing: border-box; + padding-bottom: 235px; + background: url('~images/mastodon-getting-started.png') no-repeat 0 100%; + flex: 1 0 auto; + + p { + color: $ui-secondary-color; + } + + a { + color: $ui-base-lighter-color; + } +} + +.setting-text { + color: $ui-primary-color; + background: transparent; + border: none; + border-bottom: 2px solid $ui-primary-color; + box-sizing: border-box; + display: block; + font-family: inherit; + margin-bottom: 10px; + padding: 7px 0; + width: 100%; + + &:focus, + &:active { + color: $primary-text-color; + border-bottom-color: $ui-highlight-color; + } + + @include limited-single-column('screen and (max-width: 600px)') { + font-size: 16px; + } + + &.light { + color: $ui-base-color; + border-bottom: 2px solid lighten($ui-base-color, 27%); + + &:focus, + &:active { + color: $ui-base-color; + border-bottom-color: $ui-highlight-color; + } + } +} + +@import 'boost'; + +button.icon-button i.fa-retweet { + background-position: 0 0; + height: 19px; + transition: background-position 0.9s steps(10); + transition-duration: 0s; + vertical-align: middle; + width: 22px; + + &::before { + display: none !important; + } +} + +button.icon-button.active i.fa-retweet { + transition-duration: 0.9s; + background-position: 0 100%; +} + +.status-card { + display: flex; + cursor: pointer; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + color: $ui-base-lighter-color; + margin-top: 14px; + text-decoration: none; + overflow: hidden; + + &:hover { + background: lighten($ui-base-color, 8%); + } +} + +.status-card-video, +.status-card-rich, +.status-card-photo { + margin-top: 14px; + overflow: hidden; + + iframe { + width: 100%; + height: auto; + } +} + +.status-card-photo { + display: block; + text-decoration: none; + + img { + display: block; + width: 100%; + height: auto; + margin: 0; + } +} + +.status-card-video { + iframe { + width: 100%; + height: 100%; + } +} + +.status-card__title { + display: block; + font-weight: 500; + margin-bottom: 5px; + color: $ui-primary-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-card__content { + flex: 1 1 auto; + overflow: hidden; + padding: 14px 14px 14px 8px; +} + +.status-card__description { + color: $ui-primary-color; +} + +.status-card__host { + display: block; + margin-top: 5px; + font-size: 13px; +} + +.status-card__image { + flex: 0 0 100px; + background: lighten($ui-base-color, 8%); +} + +.status-card.horizontal { + display: block; + + .status-card__image { + width: 100%; + } + + .status-card__image-image { + border-radius: 4px 4px 0 0; + } +} + +.status-card__image-image { + border-radius: 4px 0 0 4px; + display: block; + height: auto; + margin: 0; + width: 100%; +} + +.load-more { + display: block; + color: $ui-base-lighter-color; + background-color: transparent; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + width: 100%; + clear: both; + + &:hover { + background: lighten($ui-base-color, 2%); + } +} + +.missing-indicator { + text-align: center; + font-size: 16px; + font-weight: 500; + color: lighten($ui-base-color, 16%); + background: $ui-base-color; + cursor: default; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + + & > div { + background: url('~images/mastodon-not-found.png') no-repeat center -50px; + padding-top: 210px; + width: 100%; + } +} + +.column-header__wrapper { + position: relative; + flex: 0 0 auto; + + &.active { + &::before { + display: block; + content: ""; + position: absolute; + top: 35px; + left: 0; + right: 0; + margin: 0 auto; + width: 60%; + pointer-events: none; + height: 28px; + z-index: 1; + background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%); + } + } +} + +.column-header { + display: flex; + padding: 15px; + font-size: 16px; + background: lighten($ui-base-color, 4%); + flex: 0 0 auto; + cursor: pointer; + position: relative; + z-index: 2; + outline: 0; + + &.active { + box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3); + + .column-header__icon { + color: $ui-highlight-color; + text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4); + } + } + + &:focus, + &:active { + outline: 0; + } +} + +.column-header__buttons { + height: 48px; + display: flex; + margin: -15px; + margin-left: 0; +} + +.column-header__button { + background: lighten($ui-base-color, 4%); + border: 0; + color: $ui-primary-color; + cursor: pointer; + font-size: 16px; + padding: 0 15px; + + &:hover { + color: lighten($ui-primary-color, 7%); + } + + &.active { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + + &:hover { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + } + } + + // glitch - added focus ring for keyboard navigation + &:focus { + text-shadow: 0 0 4px darken($ui-highlight-color, 5%); + } +} + +.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy { + border-top: 1px solid $ui-base-color; +} + +.notification__dismiss-overlay { + overflow: hidden; + position: absolute; + top: 0; + right: 0; + bottom: -1px; + padding-left: 15px; // space for the box shadow to be visible + + z-index: 999; + align-items: center; + justify-content: flex-end; + cursor: pointer; + + display: flex; + + .wrappy { + width: $dismiss-overlay-width; + align-self: stretch; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: lighten($ui-base-color, 8%); + border-left: 1px solid lighten($ui-base-color, 20%); + box-shadow: 0 0 5px black; + border-bottom: 1px solid $ui-base-color; + } + + .ckbox { + border: 2px solid $ui-primary-color; + border-radius: 2px; + width: 30px; + height: 30px; + font-size: 20px; + color: $ui-primary-color; + text-shadow: 0 0 5px black; + display: flex; + justify-content: center; + align-items: center; + } + + &:focus { + outline: 0 !important; + + .ckbox { + box-shadow: 0 0 1px 1px $ui-highlight-color; + } + } +} + +.column-header__notif-cleaning-buttons { + display: flex; + align-items: stretch; + justify-content: space-around; + + button { + @extend .column-header__button; + background: transparent; + text-align: center; + padding: 10px 0; + white-space: pre-wrap; + } + + b { + font-weight: bold; + } +} + +// The notifs drawer with no padding to have more space for the buttons +.column-header__collapsible-inner.nopad-drawer { + padding: 0; +} + +.column-header__collapsible { + max-height: 70vh; + overflow: hidden; + overflow-y: auto; + color: $ui-primary-color; + transition: max-height 150ms ease-in-out, opacity 300ms linear; + opacity: 1; + + &.collapsed { + max-height: 0; + opacity: 0.5; + } + + &.animating { + overflow-y: hidden; + } + + // notif cleaning drawer + &.ncd { + transition: none; + &.collapsed { + max-height: 0; + opacity: 0.7; + } + } +} + +.column-header__collapsible-inner { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-header__setting-btn { + &:hover { + color: lighten($ui-primary-color, 4%); + text-decoration: underline; + } +} + +.column-header__setting-arrows { + float: right; + + .column-header__setting-btn { + padding: 0 10px; + + &:last-child { + padding-right: 0; + } + } +} + +.column-header__title { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + +.text-btn { + display: inline-block; + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + border: 0; + background: transparent; + cursor: pointer; +} + +.column-header__icon { + display: inline-block; + margin-right: 5px; +} + +.loading-indicator { + color: lighten($ui-base-color, 26%); + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + overflow: visible; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + span { + display: block; + float: left; + margin-left: 50%; + transform: translateX(-50%); + margin: 82px 0 0 50%; + white-space: nowrap; + animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); + } +} + +.loading-indicator__figure { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 0; + height: 0; + box-sizing: border-box; + border: 0 solid lighten($ui-base-color, 26%); + border-radius: 50%; + animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); +} + +@keyframes loader-figure { + 0% { + width: 0; + height: 0; + background-color: lighten($ui-base-color, 26%); + } + + 29% { + background-color: lighten($ui-base-color, 26%); + } + + 30% { + width: 42px; + height: 42px; + background-color: transparent; + border-width: 21px; + opacity: 1; + } + + 100% { + width: 42px; + height: 42px; + border-width: 0; + opacity: 0; + background-color: transparent; + } +} + +@keyframes loader-label { + 0% { opacity: 0.25; } + 30% { opacity: 1; } + 100% { opacity: 0.25; } +} + +.video-error-cover { + align-items: center; + background: $base-overlay-background; + color: $primary-text-color; + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + margin-top: 8px; + position: relative; + text-align: center; + z-index: 100; +} + +.media-spoiler { + background: $base-overlay-background; + color: $ui-primary-color; + border: 0; + width: 100%; + height: 100%; + justify-content: center; + position: relative; + text-align: center; + z-index: 100; + display: flex; + flex-direction: column; + align-items: stretch; + + .status__content > & { + margin-top: 15px; // Add margin when used bare for NSFW video player + } + + @include fullwidth-gallery; +} + +.media-spoiler__warning { + display: block; + font-size: 14px; +} + +.media-spoiler__trigger { + display: block; + font-size: 11px; + font-weight: 500; +} + +.spoiler-button { + display: none; + left: 4px; + position: absolute; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; + top: 4px; + z-index: 100; + + &.spoiler-button--visible { + display: block; + } +} + +.modal-container--preloader { + background: lighten($ui-base-color, 8%); +} + +.account--panel { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; +} + +.account--panel__button, +.detailed-status__button { + flex: 1 1 auto; + text-align: center; +} + +.column-settings__outer { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-settings__section { + color: $ui-primary-color; + cursor: default; + display: block; + font-weight: 500; + margin-bottom: 10px; +} + +.column-settings__row { + .text-btn { + margin-bottom: 15px; + } +} + +.modal-container__nav { + align-items: center; + background: rgba($base-overlay-background, 0.5); + box-sizing: border-box; + border: 0; + color: $primary-text-color; + cursor: pointer; + display: flex; + font-size: 24px; + height: 100%; + padding: 30px 15px; + position: absolute; + top: 0; +} + +.modal-container__nav--left { + left: -61px; +} + +.modal-container__nav--right { + right: -61px; +} + +.account--follows-info { + color: $primary-text-color; + position: absolute; + top: 10px; + left: 10px; + opacity: 0.7; + display: inline-block; + vertical-align: top; + background-color: rgba($base-overlay-background, 0.4); + text-transform: uppercase; + font-size: 11px; + font-weight: 500; + padding: 4px; + border-radius: 4px; +} + +.account--action-button { + position: absolute; + top: 10px; + right: 20px; +} + +.setting-toggle { + display: block; + line-height: 24px; +} + +.setting-toggle__label, +.setting-meta__label { + color: $ui-primary-color; + display: inline-block; + margin-bottom: 14px; + margin-left: 8px; + vertical-align: middle; +} + +.setting-meta__label { + color: $ui-primary-color; + float: right; +} + +.empty-column-indicator, +.error-column { + color: lighten($ui-base-color, 20%); + background: $ui-base-color; + text-align: center; + padding: 20px; + font-size: 15px; + font-weight: 400; + cursor: default; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + @supports(display: grid) { // hack to fix Chrome <57 + contain: strict; + } + + a { + color: $ui-highlight-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.error-column { + flex-direction: column; +} + +@keyframes heartbeat { + from { + transform: scale(1); + transform-origin: center center; + animation-timing-function: ease-out; + } + + 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: heartbeat 1.5s ease-in-out infinite both; +} + +.emoji-picker-dropdown__menu { + background: $simple-background-color; + position: absolute; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + margin-top: 5px; + + .emoji-mart-scroll { + transition: opacity 200ms ease; + } + + &.selecting .emoji-mart-scroll { + opacity: 0.5; + } +} + +.emoji-picker-dropdown__modifiers { + position: absolute; + top: 60px; + right: 11px; + cursor: pointer; +} + +.emoji-picker-dropdown__modifiers__menu { + position: absolute; + z-index: 4; + top: -4px; + left: -8px; + background: $simple-background-color; + border-radius: 4px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + overflow: hidden; + + button { + display: block; + cursor: pointer; + border: 0; + padding: 4px 8px; + background: transparent; + + &:hover, + &:focus, + &:active { + background: rgba($ui-secondary-color, 0.4); + } + } + + .emoji-mart-emoji { + height: 22px; + } +} + +.emoji-mart-emoji { + span { + background-repeat: no-repeat; + } +} + +.upload-area { + align-items: center; + background: rgba($base-overlay-background, 0.8); + display: flex; + height: 100%; + justify-content: center; + left: 0; + opacity: 0; + position: absolute; + top: 0; + visibility: hidden; + width: 100%; + z-index: 2000; + + * { + pointer-events: none; + } +} + +.upload-area__drop { + width: 320px; + height: 160px; + display: flex; + box-sizing: border-box; + position: relative; + padding: 8px; +} + +.upload-area__background { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 5px rgba($base-shadow-color, 0.2); +} + +.upload-area__content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: $ui-secondary-color; + font-size: 18px; + font-weight: 500; + border: 2px dashed $ui-base-lighter-color; + border-radius: 4px; +} + +.upload-progress { + padding: 10px; + color: $ui-base-lighter-color; + overflow: hidden; + display: flex; + + .fa { + font-size: 34px; + margin-right: 10px; + } + + span { + font-size: 12px; + text-transform: uppercase; + font-weight: 500; + display: block; + } +} + +.upload-progess__message { + flex: 1 1 auto; +} + +.upload-progress__backdrop { + width: 100%; + height: 6px; + border-radius: 6px; + background: $ui-base-lighter-color; + position: relative; + margin-top: 5px; +} + +.upload-progress__tracker { + position: absolute; + left: 0; + top: 0; + height: 6px; + background: $ui-highlight-color; + border-radius: 6px; +} + +.emoji-button { + display: block; + font-size: 24px; + line-height: 24px; + margin-left: 2px; + width: 24px; + outline: 0; + cursor: pointer; + + &:active, + &:focus { + outline: 0 !important; + } + + img { + filter: grayscale(100%); + opacity: 0.8; + display: block; + margin: 0; + width: 22px; + height: 22px; + margin-top: 2px; + } + + &:hover, + &:active, + &:focus { + img { + opacity: 1; + filter: none; + } + } +} + +.dropdown--active .emoji-button img { + opacity: 1; + filter: none; +} + +.privacy-dropdown__dropdown { + position: absolute; + background: $simple-background-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 4px; + margin-left: 40px; + overflow: hidden; +} + +.privacy-dropdown__option { + color: $ui-base-color; + padding: 10px; + cursor: pointer; + display: flex; + + &:hover, + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + + .privacy-dropdown__option__content { + color: $primary-text-color; + + strong { + color: $primary-text-color; + } + } + } + + &.active:hover { + background: lighten($ui-highlight-color, 4%); + } +} + +.privacy-dropdown__option__icon { + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; +} + +.privacy-dropdown__option__content { + flex: 1 1 auto; + color: darken($ui-primary-color, 24%); + + strong { + font-weight: 500; + display: block; + color: $ui-base-color; + } +} + +.privacy-dropdown.active { + .privacy-dropdown__value { + 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 { + display: block; + box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1); + } +} + +.advanced-options-dropdown { + position: relative; +} + +.advanced-options-dropdown__dropdown { + display: none; + position: absolute; + left: 0; + top: 27px; + width: 210px; + background: $simple-background-color; + border-radius: 0 4px 4px; + z-index: 2; + overflow: hidden; +} + +.advanced-options-dropdown__option { + color: $ui-base-color; + padding: 10px; + cursor: pointer; + display: flex; + + &:hover, + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + + .advanced-options-dropdown__option__content { + color: $primary-text-color; + + strong { + color: $primary-text-color; + } + } + } + + &.active:hover { + background: lighten($ui-highlight-color, 4%); + } +} + +.advanced-options-dropdown__option__toggle { + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; +} + +.advanced-options-dropdown__option__content { + flex: 1 1 auto; + color: darken($ui-primary-color, 24%); + + strong { + font-weight: 500; + display: block; + color: $ui-base-color; + } +} + +.advanced-options-dropdown.open { + .advanced-options-dropdown__value { + background: $simple-background-color; + border-radius: 4px 4px 0 0; + box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); + } + + .advanced-options-dropdown__dropdown { + display: block; + box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1); + } +} + + +.search { + position: relative; + margin-bottom: 10px; +} + +.search__input { + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + padding-right: 30px; + font-family: inherit; + background: $ui-base-color; + color: $ui-primary-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @include limited-single-column('screen and (max-width: 600px)') { + font-size: 16px; + } +} + +.search__icon { + .fa { + position: absolute; + top: 10px; + right: 10px; + z-index: 2; + display: inline-block; + opacity: 0; + transition: all 100ms linear; + font-size: 18px; + width: 18px; + height: 18px; + color: $ui-secondary-color; + cursor: default; + pointer-events: none; + + &.active { + pointer-events: auto; + opacity: 0.3; + } + } + + .fa-search { + transform: rotate(90deg); + + &.active { + pointer-events: none; + transform: rotate(0deg); + } + } + + .fa-times-circle { + top: 11px; + transform: rotate(0deg); + cursor: pointer; + + &.active { + transform: rotate(90deg); + } + + &:hover { + color: $primary-text-color; + } + } +} + +.search-results__header { + color: $ui-base-lighter-color; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid darken($ui-base-color, 4%); + padding: 15px 10px; + font-size: 14px; + font-weight: 500; +} + +.search-results__section { + background: $ui-base-color; +} + +.search-results__hashtag { + display: block; + padding: 10px; + color: $ui-secondary-color; + text-decoration: none; + + &:hover, + &:active, + &:focus { + color: lighten($ui-secondary-color, 4%); + text-decoration: underline; + } +} + +.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; + background: rgba($base-overlay-background, 0.7); +} + +.modal-root__container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + align-content: space-around; + z-index: 9999; + pointer-events: none; + user-select: none; +} + +.modal-root__modal { + pointer-events: auto; + display: flex; + z-index: 9999; +} + +.media-modal { + max-width: 80vw; + max-height: 80vh; + position: relative; + + .extended-video-player, + img, + canvas, + video { + max-width: 80vw; + max-height: 80vh; + width: auto; + height: auto; + margin: auto; + } + + .extended-video-player, + video { + display: flex; + width: 80vw; + height: 80vh; + } + + img, + canvas { + display: block; + background: url('~images/void.png') repeat; + object-fit: contain; + } + + .react-swipeable-view-container { + max-width: 80vw; + } +} + +.media-modal__content { + 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; + top: 4px; + z-index: 100; +} + +.onboarding-modal, +.error-modal, +.embed-modal { + background: $ui-secondary-color; + color: $ui-base-color; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.onboarding-modal__pager { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 420px; + + .react-swipeable-view-container > div { + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 25px; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + user-select: text; + } +} + +.error-modal__body { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 420px; + position: relative; + + & > div { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 25px; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + opacity: 0; + user-select: text; + } +} + +.error-modal__body { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +@media screen and (max-width: 550px) { + .onboarding-modal { + width: 100%; + height: 100%; + border-radius: 0; + } + + .onboarding-modal__pager { + width: 100%; + height: auto; + max-width: none; + max-height: none; + flex: 1 1 auto; + } +} + +.onboarding-modal__paginator, +.error-modal__footer { + flex: 0 0 auto; + background: darken($ui-secondary-color, 8%); + display: flex; + padding: 25px; + + & > div { + min-width: 33px; + } + + .onboarding-modal__nav, + .error-modal__nav { + color: darken($ui-secondary-color, 34%); + background-color: transparent; + border: 0; + font-size: 14px; + font-weight: 500; + padding: 0; + line-height: inherit; + height: auto; + + &:hover, + &:focus, + &:active { + color: darken($ui-secondary-color, 38%); + } + + &.onboarding-modal__done, + &.onboarding-modal__next { + color: $ui-highlight-color; + } + } +} + +.error-modal__footer { + justify-content: center; +} + +.onboarding-modal__dots { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-modal__dot { + width: 14px; + height: 14px; + border-radius: 14px; + background: darken($ui-secondary-color, 16%); + margin: 0 3px; + cursor: pointer; + + &:hover { + background: darken($ui-secondary-color, 18%); + } + + &.active { + cursor: default; + background: darken($ui-secondary-color, 24%); + } +} + +.onboarding-modal__page__wrapper { + pointer-events: none; + + &.onboarding-modal__page__wrapper--active { + pointer-events: auto; + } +} + +.onboarding-modal__page { + cursor: default; + line-height: 21px; + + h1 { + font-size: 18px; + font-weight: 500; + color: $ui-base-color; + margin-bottom: 20px; + } + + a { + color: $ui-highlight-color; + + &:hover, + &:focus, + &:active { + color: lighten($ui-highlight-color, 4%); + } + } + + p { + font-size: 16px; + color: lighten($ui-base-color, 8%); + margin-top: 10px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 500; + background: $ui-base-color; + color: $ui-secondary-color; + border-radius: 4px; + font-size: 14px; + padding: 3px 6px; + } + } +} + +.onboarding-modal__page-one { + display: flex; + align-items: center; +} + +.onboarding-modal__page-one__elephant-friend { + background: url('~images/elephant-friend-1.png') no-repeat center center / contain; + width: 155px; + height: 193px; + margin-right: 15px; +} + +@media screen and (max-width: 400px) { + .onboarding-modal__page-one { + flex-direction: column; + align-items: normal; + } + + .onboarding-modal__page-one__elephant-friend { + width: 100%; + height: 30vh; + max-height: 160px; + margin-bottom: 5vh; + } +} + +.onboarding-modal__page-two, +.onboarding-modal__page-three, +.onboarding-modal__page-four, +.onboarding-modal__page-five { + p { + text-align: left; + } + + .figure { + background: darken($ui-base-color, 8%); + color: $ui-secondary-color; + margin-bottom: 20px; + border-radius: 4px; + padding: 10px; + text-align: center; + font-size: 14px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3); + + .onboarding-modal__image { + border-radius: 4px; + margin-bottom: 10px; + } + + &.non-interactive { + pointer-events: none; + text-align: left; + } + } +} + +.onboarding-modal__page-four__columns { + .row { + display: flex; + margin-bottom: 20px; + + & > div { + flex: 1 1 0; + margin: 0 10px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + p { + text-align: center; + } + } + + &:last-child { + margin-bottom: 0; + } + } + + .column-header { + color: $primary-text-color; + } +} + +@media screen and (max-width: 320px) and (max-height: 600px) { + .onboarding-modal__page p { + font-size: 14px; + line-height: 20px; + } + + .onboarding-modal__page-two .figure, + .onboarding-modal__page-three .figure, + .onboarding-modal__page-four .figure, + .onboarding-modal__page-five .figure { + font-size: 12px; + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .row { + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .column-header { + padding: 5px; + font-size: 12px; + } +} + +.onboarding-modal__image { + border-radius: 8px; + width: 70vw; + max-width: 450px; + max-height: auto; + display: block; + margin: auto; + margin-bottom: 20px; +} + +.onboard-sliders { + display: inline-block; + max-width: 30px; + max-height: auto; + margin-left: 10px; +} + +.boost-modal, +.confirmation-modal, +.report-modal, +.actions-modal, +.mute-modal { + background: lighten($ui-secondary-color, 8%); + color: $ui-base-color; + border-radius: 8px; + overflow: hidden; + max-width: 90vw; + width: 480px; + position: relative; + flex-direction: column; + + .status__display-name { + display: flex; + } +} + +.actions-modal { + .status { + background: $white; + border-bottom-color: $ui-secondary-color; + padding-top: 10px; + padding-bottom: 10px; + } + + .dropdown-menu__separator { + border-bottom-color: $ui-secondary-color; + } +} + +.boost-modal__container { + overflow-x: scroll; + padding: 10px; + + .status { + user-select: text; + border-bottom: 0; + } +} + +.boost-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.report-modal__action-bar { + display: flex; + justify-content: space-between; + background: $ui-secondary-color; + padding: 10px; + line-height: 36px; + + & > div { + flex: 1 1 auto; + text-align: right; + color: lighten($ui-base-color, 33%); + padding-right: 10px; + } + + .button { + flex: 0 0 auto; + } +} + +.boost-modal__status-header { + font-size: 15px; +} + +.boost-modal__status-time { + float: right; + font-size: 14px; +} + +.confirmation-modal { + max-width: 85vw; + + @media screen and (min-width: 480px) { + max-width: 380px; + } +} + +.mute-modal { + line-height: 24px; +} + +.mute-modal .react-toggle { + vertical-align: middle; +} + +.report-modal__statuses, +.report-modal__comment { + padding: 10px; +} + +.report-modal__statuses { + min-height: 20vh; + max-height: 40vh; + overflow-y: auto; + overflow-x: hidden; +} + +.report-modal__comment { + .setting-text { + margin-top: 10px; + } +} + +.actions-modal { + .status { + overflow-y: auto; + max-height: 300px; + } + + max-height: 80vh; + max-width: 80vw; + + .actions-modal__item-label { + font-weight: 500; + } + + ul { + overflow-y: auto; + flex-shrink: 0; + + li:empty { + margin: 0; + } + + li:not(:empty) { + a { + color: $ui-base-color; + display: flex; + padding: 12px 16px; + font-size: 15px; + align-items: center; + text-decoration: none; + + &, + button { + transition: none; + } + + &.active, + &:hover, + &:active, + &:focus { + &, + button { + background: $ui-highlight-color; + color: $primary-text-color; + } + } + + button:first-child { + margin-right: 10px; + } + } + } + } +} + +.confirmation-modal__action-bar, +.mute-modal__action-bar { + .confirmation-modal__cancel-button, + .mute-modal__cancel-button { + background-color: transparent; + color: darken($ui-secondary-color, 34%); + font-size: 14px; + font-weight: 500; + + &:hover, + &:focus, + &:active { + color: darken($ui-secondary-color, 38%); + } + } +} + +.confirmation-modal__container, +.mute-modal__container, +.report-modal__target { + padding: 30px; + font-size: 16px; + text-align: center; + + strong { + font-weight: 500; + } +} + +.loading-bar { + background-color: $ui-highlight-color; + height: 3px; + position: absolute; + top: 0; + left: 0; +} + +.media-gallery__gifv__label { + display: block; + position: absolute; + color: $primary-text-color; + background: rgba($base-overlay-background, 0.5); + bottom: 6px; + left: 6px; + padding: 2px 6px; + border-radius: 2px; + font-size: 11px; + font-weight: 600; + z-index: 1; + pointer-events: none; + opacity: 0.9; + transition: opacity 0.1s ease; +} + +.media-gallery__gifv { + &.autoplay { + .media-gallery__gifv__label { + display: none; + } + } + + &:hover { + .media-gallery__gifv__label { + opacity: 1; + } + } +} + +.attachment-list { + display: flex; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + margin-top: 14px; + overflow: hidden; +} + +.attachment-list__icon { + flex: 0 0 auto; + color: $ui-base-lighter-color; + padding: 8px 18px; + cursor: default; + border-right: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 26px; + + .fa { + display: block; + } +} + +.attachment-list__list { + list-style: none; + padding: 4px 0; + padding-left: 8px; + display: flex; + flex-direction: column; + justify-content: center; + + li { + display: block; + padding: 4px 0; + } + + a { + text-decoration: none; + color: $ui-base-lighter-color; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } +} + +/* Media Gallery */ +.media-gallery { + box-sizing: border-box; + margin-top: 15px; + overflow: hidden; + position: relative; + background: $base-shadow-color; + width: 100%; + height: 110px; + + .detailed-status & { + margin-left: -12px; + width: calc(100% + 24px); + height: 250px; + } + + @include fullwidth-gallery; +} + +.media-gallery__item { + border: none; + box-sizing: border-box; + display: block; + float: left; + position: relative; + + &.standalone { + .media-gallery__item-gifv-thumbnail { + transform: none; + } + } +} + +.media-gallery__item-thumbnail { + cursor: zoom-in; + text-decoration: none; + width: 100%; + height: 100%; + line-height: 0; + display: flex; + + img { + width: 100%; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } + } +} + +.media-gallery__gifv { + height: 100%; + overflow: hidden; + position: relative; + width: 100%; + display: flex; + justify-content: center; +} + +.media-gallery__item-gifv-thumbnail { + cursor: zoom-in; + height: 100%; + position: relative; + z-index: 1; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } +} + +.media-gallery__item-thumbnail-label { + clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ + clip: rect(1px, 1px, 1px, 1px); + overflow: hidden; + position: absolute; +} +/* End Media Gallery */ + +/* Status Video Player */ +.status__video-player { + display: flex; + align-items: center; + background: $base-shadow-color; + box-sizing: border-box; + cursor: default; /* May not be needed */ + margin-top: 15px; + overflow: hidden; + position: relative; + width: 100%; + + @include fullwidth-gallery; +} + +.status__video-player-video { + position: relative; + width: 100%; + z-index: 1; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } +} + +.status__video-player-expand, +.status__video-player-mute { + color: $primary-text-color; + opacity: 0.8; + position: absolute; + right: 4px; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; +} + +.status__video-player-spoiler { + display: none; + color: $primary-text-color; + left: 4px; + position: absolute; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; + top: 4px; + z-index: 100; + + &.status__video-player-spoiler--visible { + display: block; + } +} + +.status__video-player-expand { + bottom: 4px; + z-index: 100; +} + +.status__video-player-mute { + top: 4px; + z-index: 5; +} + +.video-player { + overflow: hidden; + position: relative; + background: $base-shadow-color; + width: 100%; + max-width: 100%; + height: 110px; + + .detailed-status & { + margin-left: -12px; + width: calc(100% + 24px); + height: 250px; + } + + @include fullwidth-gallery; + + video { + height: 100%; + width: 100%; + z-index: 1; + } + + &.fullscreen { + width: 100% !important; + height: 100% !important; + margin: 0; + + video { + max-width: 100% !important; + max-height: 100% !important; + } + } + + &.inline { + video { + object-fit: cover; + position: relative; + top: 50%; + transform: translateY(-50%); + } + } + + &__controls { + 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) 60%, transparent); + padding: 0 10px; + opacity: 0; + transition: opacity .1s ease; + + &.active { + opacity: 1; + } + } + + &.inactive { + video, + .video-player__controls { + visibility: hidden; + } + } + + &__spoiler { + display: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 4; + border: 0; + background: $base-shadow-color; + color: $ui-primary-color; + transition: none; + pointer-events: none; + + &.active { + display: block; + pointer-events: auto; + + &:hover, + &:active, + &:focus { + color: lighten($ui-primary-color, 8%); + } + } + + &__title { + display: block; + font-size: 14px; + } + + &__subtitle { + display: block; + font-size: 11px; + font-weight: 500; + } + } + + &__buttons { + padding-bottom: 10px; + font-size: 16px; + + &.left { + float: left; + + button { + padding-right: 10px; + } + } + + &.right { + float: right; + + button { + padding-left: 10px; + } + } + + button { + background: transparent; + padding: 0; + border: 0; + color: $white; + + &:active, + &:hover, + &:focus { + color: $ui-highlight-color; + } + } + } + + &__seek { + cursor: pointer; + height: 24px; + position: relative; + + &::before { + content: ""; + width: 100%; + background: rgba($white, 0.35); + display: block; + position: absolute; + height: 4px; + top: 10px; + } + + &__progress, + &__buffer { + display: block; + position: absolute; + height: 4px; + top: 10px; + background: $ui-highlight-color; + } + + &__buffer { + background: rgba($white, 0.2); + } + + &__handle { + position: absolute; + z-index: 3; + opacity: 0; + border-radius: 50%; + width: 12px; + height: 12px; + top: 6px; + margin-left: -6px; + transition: opacity .1s ease; + background: $ui-highlight-color; + pointer-events: none; + + &.active { + opacity: 1; + } + } + + &:hover { + .video-player__seek__handle { + opacity: 1; + } + } + } +} + +.media-spoiler-video { + background-size: cover; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + margin-top: 15px; + position: relative; + width: 100%; + + @include fullwidth-gallery; + + border: 0; + display: block; +} + +.media-spoiler-video-play-icon { + border-radius: 100px; + color: rgba($primary-text-color, 0.8); + font-size: 36px; + left: 50%; + padding: 5px; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); +} +/* End Video Player */ + +.account-gallery__container { + margin: -2px; + padding: 4px; + display: flex; + flex-wrap: wrap; +} + +.account-gallery__item { + flex: 1 1 auto; + width: calc(100% / 3 - 4px); + height: 95px; + margin: 2px; + + a { + display: block; + width: 100%; + height: 100%; + background-color: $base-overlay-background; + background-size: cover; + background-position: center; + position: relative; + color: inherit; + text-decoration: none; + + &:hover, + &:active, + &:focus { + outline: 0; + } + } +} + +.account-section-headline { + color: $ui-base-lighter-color; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid lighten($ui-base-color, 4%); + padding: 15px 10px; + font-size: 14px; + font-weight: 500; + position: relative; + cursor: default; + + &::before, + &::after { + display: block; + content: ""; + position: absolute; + bottom: 0; + left: 18px; + width: 0; + height: 0; + border-style: solid; + border-width: 0 10px 10px; + border-color: transparent transparent lighten($ui-base-color, 4%); + } + + &::after { + bottom: -1px; + border-color: transparent transparent $ui-base-color; + } +} + +::-webkit-scrollbar-thumb { + 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; + + img { + width: 200px; + opacity: 0.5; + animation: flicker 4s infinite; + } + + div { + font-size: 14px; + margin: 30px auto; + color: $ui-secondary-color; + max-width: 400px; + + a { + color: $ui-highlight-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + } +} + +@keyframes flicker { + 0% { opacity: 1; } + 30% { opacity: 0.75; } + 100% { opacity: 1; } +} + +@media screen and (max-width: 630px) and (max-height: 400px) { + $duration: 400ms; + $delay: 100ms; + + .tabs-bar, + .search { + will-change: margin-top; + transition: margin-top $duration $delay; + } + + .navigation-bar { + will-change: padding-bottom; + transition: padding-bottom $duration $delay; + } + + .navigation-bar { + & > a:first-child { + will-change: margin-top, margin-left, width; + transition: margin-top $duration $delay, margin-left $duration ($duration + $delay); + } + + & > .navigation-bar__profile-edit { + will-change: margin-top; + transition: margin-top $duration $delay; + } + + & > .icon-button { + will-change: opacity; + transition: opacity $duration $delay; + } + } + + .is-composing { + .tabs-bar, + .search { + margin-top: -50px; + } + + .navigation-bar { + padding-bottom: 0; + + & > a:first-child { + margin-top: -50px; + margin-left: -40px; + } + + .navigation-bar__profile { + padding-top: 2px; + } + + .navigation-bar__profile-edit { + position: absolute; + margin-top: -50px; + } + + .icon-button { + pointer-events: auto; + opacity: 1; + } + } + } + + // fixes for the navbar-under mode + .is-composing.navbar-under { + .search { + margin-top: -20px; + margin-bottom: -20px; + .search__icon { + display: none; + } + } + } +} + +// more fixes for the navbar-under mode +@mixin fix-margins-for-navbar-under { + .tabs-bar { + margin-top: 0 !important; + margin-bottom: -6px !important; + } +} + +.single-column.navbar-under { + @include fix-margins-for-navbar-under; +} + +.auto-columns.navbar-under { + @media screen and (max-width: 360px) { + @include fix-margins-for-navbar-under; + } +} + +.auto-columns.navbar-under .react-swipeable-view-container .columns-area, +.single-column.navbar-under .react-swipeable-view-container .columns-area { + @media screen and (max-width: 360px) { + height: 100% !important; + } +} + +.embed-modal { + max-width: 80vw; + max-height: 80vh; + + h4 { + padding: 30px; + font-weight: 500; + font-size: 16px; + text-align: center; + } + + .embed-modal__container { + padding: 10px; + + .hint { + margin-bottom: 15px; + } + + .embed-modal__html { + color: $ui-secondary-color; + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + font-family: 'mastodon-font-monospace', monospace; + background: $ui-base-color; + color: $ui-primary-color; + font-size: 14px; + margin: 0; + margin-bottom: 15px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + } + + .embed-modal__iframe { + width: 400px; + max-width: 100%; + overflow: hidden; + border: 0; + } + } +} + +@import 'doodle'; diff --git a/app/javascript/themes/glitch/styles/containers.scss b/app/javascript/themes/glitch/styles/containers.scss new file mode 100644 index 000000000..af2589e23 --- /dev/null +++ b/app/javascript/themes/glitch/styles/containers.scss @@ -0,0 +1,116 @@ +.container { + width: 700px; + margin: 0 auto; + margin-top: 40px; + + @media screen and (max-width: 740px) { + width: 100%; + margin: 0; + } +} + +.logo-container { + margin: 100px auto; + margin-bottom: 50px; + + @media screen and (max-width: 400px) { + margin: 30px auto; + margin-bottom: 20px; + } + + h1 { + display: flex; + justify-content: center; + align-items: center; + + img { + height: 42px; + margin-right: 10px; + } + + a { + display: flex; + justify-content: center; + align-items: center; + color: $primary-text-color; + text-decoration: none; + outline: 0; + padding: 12px 16px; + line-height: 32px; + font-family: 'mastodon-font-display', sans-serif; + font-weight: 500; + font-size: 14px; + } + } +} + +.compose-standalone { + .compose-form { + width: 400px; + margin: 0 auto; + padding: 20px 0; + margin-top: 40px; + box-sizing: border-box; + + @media screen and (max-width: 400px) { + width: 100%; + margin-top: 0; + padding: 20px; + } + } +} + +.account-header { + width: 400px; + margin: 0 auto; + display: flex; + font-size: 13px; + line-height: 18px; + box-sizing: border-box; + padding: 20px 0; + padding-bottom: 0; + margin-bottom: -30px; + margin-top: 40px; + + @media screen and (max-width: 440px) { + width: 100%; + margin: 0; + margin-bottom: 10px; + padding: 20px; + padding-bottom: 0; + } + + .avatar { + width: 40px; + height: 40px; + margin-right: 8px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + } + } + + .name { + flex: 1 1 auto; + color: $ui-secondary-color; + width: calc(100% - 88px); + + .username { + display: block; + font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; + } + } + + .logout-link { + display: block; + font-size: 32px; + line-height: 40px; + margin-left: 8px; + } +} diff --git a/app/javascript/themes/glitch/styles/doodle.scss b/app/javascript/themes/glitch/styles/doodle.scss new file mode 100644 index 000000000..a4a1cfc84 --- /dev/null +++ b/app/javascript/themes/glitch/styles/doodle.scss @@ -0,0 +1,86 @@ +$doodleBg: #d9e1e8; +.doodle-modal { + @extend .boost-modal; + width: unset; +} + +.doodle-modal__container { + background: $doodleBg; + text-align: center; + line-height: 0; // remove weird gap under canvas + canvas { + border: 5px solid $doodleBg; + } +} + +.doodle-modal__action-bar { + @extend .boost-modal__action-bar; + + .filler { + flex-grow: 1; + margin: 0; + padding: 0; + } + + .doodle-toolbar { + line-height: 1; + + display: flex; + flex-direction: column; + flex-grow: 0; + justify-content: space-around; + + &.with-inputs { + label { + display: inline-block; + width: 70px; + text-align: right; + margin-right: 2px; + } + + input[type="number"],input[type="text"] { + width: 40px; + } + span.val { + display: inline-block; + text-align: left; + width: 50px; + } + } + } + + .doodle-palette { + padding-right: 0 !important; + border: 1px solid black; + line-height: .2rem; + flex-grow: 0; + background: white; + + button { + appearance: none; + width: 1rem; + height: 1rem; + margin: 0; padding: 0; + text-align: center; + color: black; + text-shadow: 0 0 1px white; + cursor: pointer; + box-shadow: inset 0 0 1px rgba(white, .5); + border: 1px solid black; + outline-offset:-1px; + + &.foreground { + outline: 1px dashed white; + } + + &.background { + outline: 1px dashed red; + } + + &.foreground.background { + outline: 1px dashed red; + border-color: white; + } + } + } +} diff --git a/app/javascript/themes/glitch/styles/emoji_picker.scss b/app/javascript/themes/glitch/styles/emoji_picker.scss new file mode 100644 index 000000000..2b46d30fc --- /dev/null +++ b/app/javascript/themes/glitch/styles/emoji_picker.scss @@ -0,0 +1,199 @@ +.emoji-mart { + &, + * { + box-sizing: border-box; + line-height: 1.15; + } + + font-size: 13px; + display: inline-block; + color: $ui-base-color; + + .emoji-mart-emoji { + padding: 6px; + } +} + +.emoji-mart-bar { + border: 0 solid darken($ui-secondary-color, 8%); + + &:first-child { + border-bottom-width: 1px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background: $ui-secondary-color; + } + + &:last-child { + border-top-width: 1px; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + display: none; + } +} + +.emoji-mart-anchors { + display: flex; + justify-content: space-between; + padding: 0 6px; + color: $ui-primary-color; + line-height: 0; +} + +.emoji-mart-anchor { + position: relative; + flex: 1; + text-align: center; + padding: 12px 4px; + overflow: hidden; + transition: color .1s ease-out; + cursor: pointer; + + &:hover { + color: darken($ui-primary-color, 4%); + } +} + +.emoji-mart-anchor-selected { + color: darken($ui-highlight-color, 3%); + + &:hover { + color: darken($ui-highlight-color, 3%); + } + + .emoji-mart-anchor-bar { + bottom: 0; + } +} + +.emoji-mart-anchor-bar { + position: absolute; + bottom: -3px; + left: 0; + width: 100%; + height: 3px; + background-color: darken($ui-highlight-color, 3%); +} + +.emoji-mart-anchors { + i { + display: inline-block; + width: 100%; + max-width: 22px; + } + + svg { + fill: currentColor; + max-height: 18px; + } +} + +.emoji-mart-scroll { + overflow-y: scroll; + height: 270px; + max-height: 35vh; + padding: 0 6px 6px; + background: $simple-background-color; + will-change: transform; +} + +.emoji-mart-search { + padding: 10px; + padding-right: 45px; + background: $simple-background-color; + + input { + font-size: 14px; + font-weight: 400; + padding: 7px 9px; + font-family: inherit; + display: block; + width: 100%; + background: rgba($ui-secondary-color, 0.3); + color: $ui-primary-color; + border: 1px solid $ui-secondary-color; + border-radius: 4px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + } +} + +.emoji-mart-category .emoji-mart-emoji { + cursor: pointer; + + span { + z-index: 1; + position: relative; + text-align: center; + } + + &:hover::before { + z-index: 0; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba($ui-secondary-color, 0.7); + border-radius: 100%; + } +} + +.emoji-mart-category-label { + z-index: 2; + position: relative; + position: -webkit-sticky; + position: sticky; + top: 0; + + span { + display: block; + width: 100%; + font-weight: 500; + padding: 5px 6px; + background: $simple-background-color; + } +} + +.emoji-mart-emoji { + position: relative; + display: inline-block; + font-size: 0; + + span { + width: 22px; + height: 22px; + } +} + +.emoji-mart-no-results { + font-size: 14px; + text-align: center; + padding-top: 70px; + color: $ui-primary-color; + + .emoji-mart-category-label { + display: none; + } + + .emoji-mart-no-results-label { + margin-top: .2em; + } + + .emoji-mart-emoji:hover::before { + content: none; + } +} + +.emoji-mart-preview { + display: none; +} diff --git a/app/javascript/themes/glitch/styles/footer.scss b/app/javascript/themes/glitch/styles/footer.scss new file mode 100644 index 000000000..2d953b34e --- /dev/null +++ b/app/javascript/themes/glitch/styles/footer.scss @@ -0,0 +1,30 @@ +.footer { + text-align: center; + margin-top: 30px; + font-size: 12px; + color: darken($ui-secondary-color, 25%); + + .domain { + font-weight: 500; + + a { + color: inherit; + text-decoration: none; + } + } + + .powered-by, + .single-user-login { + font-weight: 400; + + a { + color: inherit; + text-decoration: underline; + font-weight: 500; + + &:hover { + text-decoration: none; + } + } + } +} diff --git a/app/javascript/themes/glitch/styles/forms.scss b/app/javascript/themes/glitch/styles/forms.scss new file mode 100644 index 000000000..61fcf286f --- /dev/null +++ b/app/javascript/themes/glitch/styles/forms.scss @@ -0,0 +1,540 @@ +code { + font-family: 'mastodon-font-monospace', monospace; + font-weight: 400; +} + +.form-container { + max-width: 400px; + padding: 20px; + margin: 0 auto; +} + +.simple_form { + .input { + margin-bottom: 15px; + overflow: hidden; + } + + span.hint { + display: block; + color: $ui-primary-color; + font-size: 12px; + 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; + + &.subtle-hint { + text-align: center; + font-size: 12px; + line-height: 18px; + margin-top: 15px; + margin-bottom: 0; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + } + } + } + + .card { + margin-bottom: 15px; + } + + strong { + font-weight: 500; + } + + .label_input { + display: flex; + + label { + flex: 0 0 auto; + } + + input { + flex: 1 1 auto; + } + } + + .input.with_label { + padding: 15px 0; + margin-bottom: 0; + + .label_input { + flex-wrap: wrap; + align-items: flex-start; + } + + &.select .label_input { + align-items: initial; + } + + .label_input > label { + font-family: inherit; + font-size: 16px; + color: $primary-text-color; + display: block; + padding-top: 5px; + margin-bottom: 5px; + flex: 1; + min-width: 150px; + word-wrap: break-word; + + &.select { + flex: 0; + } + + & ~ * { + margin-left: 10px; + } + } + + ul { + flex: 390px; + } + + &.boolean { + padding: initial; + margin-bottom: initial; + + .label_input > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + label.checkbox { + position: relative; + padding-left: 25px; + flex: 1 1 auto; + } + } + } + + .input.with_block_label { + & > label { + font-family: inherit; + font-size: 16px; + color: $primary-text-color; + display: block; + padding-top: 5px; + } + + .hint { + margin-bottom: 15px; + } + + li { + float: left; + width: 50%; + } + } + + .fields-group { + margin-bottom: 25px; + } + + .input.radio_buttons .radio label { + margin-bottom: 5px; + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + .input.boolean { + margin-bottom: 5px; + + label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + label.checkbox { + position: relative; + padding-left: 25px; + flex: 1 1 auto; + } + + input[type=checkbox] { + position: absolute; + left: 0; + top: 5px; + margin: 0; + } + + .hint { + padding-left: 25px; + margin-left: 0; + } + } + + .check_boxes { + .checkbox { + label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + position: relative; + padding-top: 5px; + padding-left: 25px; + flex: 1 1 auto; + } + + input[type=checkbox] { + position: absolute; + left: 0; + top: 5px; + margin: 0; + } + } + } + + input[type=text], + input[type=number], + input[type=email], + input[type=password], + textarea { + background: transparent; + box-sizing: border-box; + border: 0; + border-bottom: 2px solid $ui-primary-color; + border-radius: 2px 2px 0 0; + padding: 7px 4px; + font-size: 16px; + color: $primary-text-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + + &:invalid { + box-shadow: none; + } + + &:focus:invalid { + border-bottom-color: $error-value-color; + } + + &:required:valid { + border-bottom-color: $valid-value-color; + } + + &:active, + &:focus { + border-bottom-color: $ui-highlight-color; + background: rgba($base-overlay-background, 0.1); + } + } + + .input.field_with_errors { + label { + color: $error-value-color; + } + + input[type=text], + input[type=email], + input[type=password] { + border-bottom-color: $error-value-color; + } + + .error { + display: block; + font-weight: 500; + color: $error-value-color; + margin-top: 4px; + } + } + + .actions { + margin-top: 30px; + display: flex; + } + + button, + .button, + .block-button { + display: block; + width: 100%; + border: 0; + border-radius: 4px; + background: $ui-highlight-color; + color: $primary-text-color; + font-size: 18px; + line-height: inherit; + height: auto; + padding: 10px; + text-transform: uppercase; + text-decoration: none; + text-align: center; + box-sizing: border-box; + cursor: pointer; + font-weight: 500; + outline: 0; + margin-bottom: 10px; + margin-right: 10px; + + &:last-child { + margin-right: 0; + } + + &:hover { + background-color: lighten($ui-highlight-color, 5%); + } + + &:active, + &:focus { + background-color: darken($ui-highlight-color, 5%); + } + + &.negative { + background: $error-value-color; + + &:hover { + background-color: lighten($error-value-color, 5%); + } + + &:active, + &:focus { + background-color: darken($error-value-color, 5%); + } + } + } + + select { + font-size: 16px; + max-height: 29px; + } + + .input-with-append { + position: relative; + + .input input { + padding-right: 127px; + } + + .append { + position: absolute; + right: 0; + top: 0; + padding: 7px 4px; + padding-bottom: 9px; + font-size: 16px; + color: $ui-base-lighter-color; + font-family: inherit; + pointer-events: none; + cursor: default; + } + } +} + +.flash-message { + background: lighten($ui-base-color, 8%); + color: $ui-primary-color; + border-radius: 4px; + padding: 15px 10px; + margin-bottom: 30px; + box-shadow: 0 0 5px rgba($base-shadow-color, 0.2); + text-align: center; + + p { + margin-bottom: 15px; + } + + .oauth-code { + color: $ui-secondary-color; + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + font-family: 'mastodon-font-monospace', monospace; + background: $ui-base-color; + color: $ui-primary-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + } + + strong { + font-weight: 500; + } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } +} + +.form-footer { + margin-top: 30px; + text-align: center; + + a { + color: $ui-primary-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.oauth-prompt, +.follow-prompt { + margin-bottom: 30px; + text-align: center; + color: $ui-primary-color; + + h2 { + font-size: 16px; + margin-bottom: 30px; + } + + strong { + color: $ui-secondary-color; + font-weight: 500; + } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } +} + +.qr-wrapper { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.qr-code { + flex: 0 0 auto; + background: $simple-background-color; + padding: 4px; + margin: 0 10px 20px 0; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + display: inline-block; + + svg { + display: block; + margin: 0; + } +} + +.qr-alternative { + margin-bottom: 20px; + color: $ui-secondary-color; + flex: 150px; + + samp { + display: block; + font-size: 14px; + } +} + +.table-form { + p { + margin-bottom: 15px; + + strong { + font-weight: 500; + } + } +} + +.simple_form, +.table-form { + .warning { + box-sizing: border-box; + background: rgba($error-value-color, 0.5); + color: $primary-text-color; + text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3); + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; + + a { + color: $primary-text-color; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + strong { + font-weight: 600; + display: block; + margin-bottom: 5px; + + .fa { + font-weight: 400; + } + } + } +} + +.action-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + + .actions, + .pagination { + flex: 1 1 auto; + } + + .actions { + padding: 30px 0; + padding-right: 20px; + flex: 0 0 auto; + } +} + +.post-follow-actions { + text-align: center; + color: $ui-primary-color; + + div { + margin-bottom: 4px; + } +} diff --git a/app/javascript/themes/glitch/styles/index.scss b/app/javascript/themes/glitch/styles/index.scss new file mode 100644 index 000000000..c962a1f62 --- /dev/null +++ b/app/javascript/themes/glitch/styles/index.scss @@ -0,0 +1,22 @@ +@import 'mixins'; +@import 'variables'; +@import 'styles/fonts/roboto'; +@import 'styles/fonts/roboto-mono'; +@import 'styles/fonts/montserrat'; + +@import 'reset'; +@import 'basics'; +@import 'containers'; +@import 'lists'; +@import 'footer'; +@import 'compact_header'; +@import 'landing_strip'; +@import 'forms'; +@import 'accounts'; +@import 'stream_entries'; +@import 'components'; +@import 'emoji_picker'; +@import 'about'; +@import 'tables'; +@import 'admin'; +@import 'rtl'; diff --git a/app/javascript/themes/glitch/styles/landing_strip.scss b/app/javascript/themes/glitch/styles/landing_strip.scss new file mode 100644 index 000000000..0bf9daafd --- /dev/null +++ b/app/javascript/themes/glitch/styles/landing_strip.scss @@ -0,0 +1,36 @@ +.landing-strip, +.memoriam-strip { + background: rgba(darken($ui-base-color, 7%), 0.8); + color: $ui-primary-color; + font-weight: 400; + padding: 14px; + border-radius: 4px; + margin-bottom: 20px; + display: flex; + align-items: center; + + strong, + a { + font-weight: 500; + } + + a { + color: inherit; + text-decoration: underline; + } + + .logo { + width: 30px; + height: 30px; + flex: 0 0 auto; + margin-right: 15px; + } + + @media screen and (max-width: 740px) { + margin-bottom: 0; + } +} + +.memoriam-strip { + background: rgba($base-shadow-color, 0.7); +} diff --git a/app/javascript/themes/glitch/styles/lists.scss b/app/javascript/themes/glitch/styles/lists.scss new file mode 100644 index 000000000..6019cd800 --- /dev/null +++ b/app/javascript/themes/glitch/styles/lists.scss @@ -0,0 +1,19 @@ +.no-list { + list-style: none; + + li { + display: inline-block; + margin: 0 5px; + } +} + +.recovery-codes { + list-style: none; + margin: 0 auto; + + li { + font-size: 125%; + line-height: 1.5; + letter-spacing: 1px; + } +} diff --git a/app/javascript/themes/glitch/styles/reset copy.scss b/app/javascript/themes/glitch/styles/reset copy.scss new file mode 100644 index 000000000..cc5ba9d7c --- /dev/null +++ b/app/javascript/themes/glitch/styles/reset copy.scss @@ -0,0 +1,91 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +body { + line-height: 1; +} + +ol, ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + background: lighten($ui-base-color, 4%); + border: 0px none $base-border-color; + border-radius: 50px; +} + +::-webkit-scrollbar-thumb:hover { + background: lighten($ui-base-color, 6%); +} + +::-webkit-scrollbar-thumb:active { + background: lighten($ui-base-color, 4%); +} + +::-webkit-scrollbar-track { + border: 0px none $base-border-color; + border-radius: 0; + background: rgba($base-overlay-background, 0.1); +} + +::-webkit-scrollbar-track:hover { + background: $ui-base-color; +} + +::-webkit-scrollbar-track:active { + background: $ui-base-color; +} + +::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/app/javascript/themes/glitch/styles/reset.scss b/app/javascript/themes/glitch/styles/reset.scss new file mode 100644 index 000000000..cc5ba9d7c --- /dev/null +++ b/app/javascript/themes/glitch/styles/reset.scss @@ -0,0 +1,91 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +body { + line-height: 1; +} + +ol, ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + background: lighten($ui-base-color, 4%); + border: 0px none $base-border-color; + border-radius: 50px; +} + +::-webkit-scrollbar-thumb:hover { + background: lighten($ui-base-color, 6%); +} + +::-webkit-scrollbar-thumb:active { + background: lighten($ui-base-color, 4%); +} + +::-webkit-scrollbar-track { + border: 0px none $base-border-color; + border-radius: 0; + background: rgba($base-overlay-background, 0.1); +} + +::-webkit-scrollbar-track:hover { + background: $ui-base-color; +} + +::-webkit-scrollbar-track:active { + background: $ui-base-color; +} + +::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/app/javascript/themes/glitch/styles/rtl.scss b/app/javascript/themes/glitch/styles/rtl.scss new file mode 100644 index 000000000..67bfa8a38 --- /dev/null +++ b/app/javascript/themes/glitch/styles/rtl.scss @@ -0,0 +1,254 @@ +body.rtl { + direction: rtl; + + .column-link__icon, + .column-header__icon { + margin-right: 0; + margin-left: 5px; + } + + .character-counter__wrapper { + margin-right: 8px; + margin-left: 16px; + } + + .navigation-bar__profile { + margin-left: 0; + margin-right: 8px; + } + + .search__input { + padding-right: 10px; + padding-left: 30px; + } + + .search__icon .fa { + right: auto; + left: 10px; + } + + .column-header__buttons { + left: 0; + right: auto; + } + + .column-header__back-button { + padding-left: 5px; + padding-right: 0; + } + + .column-header__setting-arrows { + float: left; + } + + .compose-form__modifiers { + border-radius: 0 0 0 4px; + } + + .setting-toggle { + margin-left: 0; + margin-right: 8px; + } + + .setting-meta__label { + float: left; + } + + .status__avatar { + left: auto; + right: 10px; + } + + .status, + .activity-stream .status.light { + padding-left: 10px; + padding-right: 68px; + } + + .status__info .status__display-name, + .activity-stream .status.light .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .activity-stream .pre-header { + padding-right: 68px; + padding-left: 0; + } + + .status__prepend { + margin-left: 0; + margin-right: 68px; + } + + .status__prepend-icon-wrapper { + left: auto; + right: -26px; + } + + .activity-stream .pre-header .pre-header__icon { + left: auto; + right: 42px; + } + + .account__avatar-overlay-overlay { + right: auto; + left: 0; + } + + .column-back-button--slim-button { + right: auto; + left: 0; + } + + .status__relative-time, + .activity-stream .status.light .status__header .status__meta { + float: left; + } + + .activity-stream .detailed-status.light .detailed-status__display-name > div { + float: right; + margin-right: 0; + margin-left: 10px; + } + + .activity-stream .detailed-status.light .detailed-status__meta span > span { + margin-left: 0; + margin-right: 6px; + } + + .status__action-bar-button { + float: right; + margin-right: 0; + margin-left: 18px; + } + + .status__action-bar-dropdown { + float: right; + } + + .privacy-dropdown__dropdown { + margin-left: 0; + margin-right: 40px; + } + + .privacy-dropdown__option__icon { + margin-left: 10px; + margin-right: 0; + } + + .detailed-status__display-avatar { + margin-right: 0; + margin-left: 10px; + float: right; + } + + .detailed-status__favorites, + .detailed-status__reblogs { + margin-left: 0; + margin-right: 6px; + } + + .fa-ul { + margin-left: 0; + margin-left: 2.14285714em; + } + + .fa-li { + left: auto; + right: -2.14285714em; + } + + .admin-wrapper .sidebar ul a i.fa, + a.table-action-link i.fa { + margin-right: 0; + margin-left: 5px; + } + + .simple_form .check_boxes .checkbox label, + .simple_form .input.with_label.boolean label.checkbox { + padding-left: 0; + padding-right: 25px; + } + + .simple_form .check_boxes .checkbox input[type="checkbox"], + .simple_form .input.boolean input[type="checkbox"] { + left: auto; + right: 0; + } + + .simple_form .input-with-append .input input { + padding-left: 127px; + padding-right: 0; + } + + .simple_form .input-with-append .append { + right: auto; + left: 0; + } + + .table th, + .table td { + text-align: right; + } + + .filters .filter-subset { + margin-right: 0; + margin-left: 45px; + } + + .landing-page .header-wrapper .mascot { + right: 60px; + left: auto; + } + + .landing-page .header .hero .floats .float-1 { + left: -120px; + right: auto; + } + + .landing-page .header .hero .floats .float-2 { + left: 210px; + right: auto; + } + + .landing-page .header .hero .floats .float-3 { + left: 110px; + right: auto; + } + + .landing-page .header .links .brand img { + left: 0; + } + + .landing-page .fa-external-link { + padding-right: 5px; + padding-left: 0 !important; + } + + .landing-page .features #mastodon-timeline { + margin-right: 0; + margin-left: 30px; + } + + @media screen and (min-width: 631px) { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + + &:first-child { + padding-left: 5px; + padding-right: 10px; + } + } + + .columns-area > div { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + } + } + } +} diff --git a/app/javascript/themes/glitch/styles/stream_entries.scss b/app/javascript/themes/glitch/styles/stream_entries.scss new file mode 100644 index 000000000..453070b7c --- /dev/null +++ b/app/javascript/themes/glitch/styles/stream_entries.scss @@ -0,0 +1,335 @@ +.activity-stream { + clear: both; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + .entry { + background: $simple-background-color; + + .detailed-status.light, + .status.light { + border-bottom: 1px solid $ui-secondary-color; + animation: none; + } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-bottom: 0; + border-radius: 0 0 4px 4px; + } + } + + &:first-child { + &, + .detailed-status.light, + .status.light { + border-radius: 4px 4px 0 0; + } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-radius: 4px; + } + } + } + + @media screen and (max-width: 740px) { + &, + .detailed-status.light, + .status.light { + border-radius: 0 !important; + } + } + } + + &.with-header { + .entry { + &:first-child { + &, + .detailed-status.light, + .status.light { + border-radius: 0; + } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-radius: 0 0 4px 4px; + } + } + } + } + } + + .status.light { + padding: 14px 14px 14px (48px + 14px * 2); + position: relative; + min-height: 48px; + cursor: default; + + .status__header { + font-size: 15px; + + .status__meta { + float: right; + font-size: 14px; + + .status__relative-time { + color: $ui-primary-color; + } + } + } + + .status__display-name { + display: block; + max-width: 100%; + padding-right: 25px; + color: $ui-base-color; + } + + .status__avatar { + position: absolute; + @include avatar-size(48px); + margin-left: -62px; + + & > div { + @include avatar-size(48px); + } + + img { + @include avatar-radius(); + display: block; + } + } + + .display-name { + display: block; + max-width: 100%; + //overflow: hidden; + //white-space: nowrap; + //text-overflow: ellipsis; + + strong { + font-weight: 500; + color: $ui-base-color; + } + + span { + font-size: 14px; + color: $ui-primary-color; + } + } + + .status__content { + color: $ui-base-color; + + a { + color: $ui-highlight-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + } + + .detailed-status.light { + padding: 14px; + background: $simple-background-color; + cursor: default; + + .detailed-status__display-name { + display: block; + overflow: hidden; + margin-bottom: 15px; + + & > div { + float: left; + margin-right: 10px; + } + + .display-name { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + strong { + font-weight: 500; + color: $ui-base-color; + } + + span { + font-size: 14px; + color: $ui-primary-color; + } + } + } + + .avatar { + @include avatar-size(48px); + + img { + @include avatar-radius(); + display: block; + } + } + + .status__content { + color: $ui-base-color; + + a { + color: $ui-highlight-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + + .detailed-status__meta { + margin-top: 15px; + color: $ui-primary-color; + font-size: 14px; + line-height: 18px; + + a { + color: inherit; + } + + span > span { + font-weight: 500; + font-size: 12px; + margin-left: 6px; + display: inline-block; + } + } + + .status-card { + border-color: lighten($ui-secondary-color, 4%); + color: darken($ui-primary-color, 4%); + + &:hover { + background: lighten($ui-secondary-color, 4%); + } + } + + .status-card__title, + .status-card__description { + color: $ui-base-color; + } + + .status-card__image { + background: $ui-secondary-color; + } + } + + .media-spoiler { + background: $ui-primary-color; + color: $white; + transition: all 100ms linear; + + &:hover, + &:active, + &:focus { + background: darken($ui-primary-color, 5%); + color: unset; + } + } + + .pre-header { + padding: 14px 0; + padding-left: (48px + 14px * 2); + padding-bottom: 0; + margin-bottom: -4px; + color: $ui-primary-color; + font-size: 14px; + position: relative; + + .pre-header__icon { + position: absolute; + left: (48px + 14px * 2 - 30px); + } + + .status__display-name.muted strong { + color: $ui-primary-color; + } + } + + .open-in-web-link { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.embed { + .activity-stream { + box-shadow: none; + + .entry { + + .detailed-status.light { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + + .detailed-status__display-name { + flex: 1; + margin: 0 5px 15px 0; + } + + .button.button-secondary.logo-button { + flex: 0 auto; + font-size: 14px; + + svg { + width: 20px; + height: auto; + vertical-align: middle; + margin-right: 5px; + + path:first-child { + fill: $ui-primary-color; + } + + path:last-child { + fill: $simple-background-color; + } + } + + &:active, + &:focus, + &:hover { + svg path:first-child { + fill: lighten($ui-primary-color, 4%); + } + } + } + + .status__content, + .detailed-status__meta { + flex: 100%; + } + } + } + } +} diff --git a/app/javascript/themes/glitch/styles/tables.scss b/app/javascript/themes/glitch/styles/tables.scss new file mode 100644 index 000000000..ad46f5f9f --- /dev/null +++ b/app/javascript/themes/glitch/styles/tables.scss @@ -0,0 +1,76 @@ +.table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + + th, + td { + padding: 8px; + line-height: 18px; + vertical-align: top; + border-top: 1px solid $ui-base-color; + text-align: left; + } + + & > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid $ui-base-color; + border-top: 0; + font-weight: 500; + } + + & > tbody > tr > th { + font-weight: 500; + } + + & > tbody > tr:nth-child(odd) > td, + & > tbody > tr:nth-child(odd) > th { + background: $ui-base-color; + } + + a { + color: $ui-highlight-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + strong { + font-weight: 500; + } + + &.inline-table > tbody > tr:nth-child(odd) > td, + &.inline-table > tbody > tr:nth-child(odd) > th { + background: transparent; + } +} + +.table-wrapper { + overflow: auto; + margin-bottom: 20px; +} + +samp { + font-family: 'mastodon-font-monospace', monospace; +} + +a.table-action-link { + text-decoration: none; + display: inline-block; + margin-right: 5px; + padding: 0 10px; + color: rgba($primary-text-color, 0.7); + font-weight: 500; + + &:hover { + color: $primary-text-color; + } + + i.fa { + font-weight: 400; + margin-right: 5px; + } +} diff --git a/app/javascript/themes/glitch/styles/variables.scss b/app/javascript/themes/glitch/styles/variables.scss new file mode 100644 index 000000000..f42d9c8c5 --- /dev/null +++ b/app/javascript/themes/glitch/styles/variables.scss @@ -0,0 +1,35 @@ +// Commonly used web colors +$black: #000000; // Black +$white: #ffffff; // White +$success-green: #79bd9a; // Padua +$error-red: #df405a; // Cerise +$warning-red: #ff5050; // Sunset Orange +$gold-star: #ca8f04; // Dark Goldenrod + +// Values from the classic Mastodon UI +$classic-base-color: #282c37; // Midnight Express +$classic-primary-color: #9baec8; // Echo Blue +$classic-secondary-color: #d9e1e8; // Pattens Blue +$classic-highlight-color: #2b90d9; // Summer Sky + +// Variables for defaults in UI +$base-shadow-color: $black !default; +$base-overlay-background: $black !default; +$base-border-color: $white !default; +$simple-background-color: $white !default; +$primary-text-color: $white !default; +$valid-value-color: $success-green !default; +$error-value-color: $error-red !default; + +// Tell UI to use selected colors +$ui-base-color: $classic-base-color !default; // Darkest +$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest +$ui-primary-color: $classic-primary-color !default; // Lighter +$ui-secondary-color: $classic-secondary-color !default; // Lightest +$ui-highlight-color: $classic-highlight-color !default; // Vibrant + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; + +// More variables +$dismiss-overlay-width: 4rem; diff --git a/app/javascript/themes/glitch/theme.yml b/app/javascript/themes/glitch/theme.yml new file mode 100644 index 000000000..cf3fa32c2 --- /dev/null +++ b/app/javascript/themes/glitch/theme.yml @@ -0,0 +1,25 @@ +# (REQUIRED) The location of the pack files. +pack: + about: packs/about.js + admin: null + common: packs/common.js + embed: null + home: packs/home.js + public: packs/public.js + settings: null + share: packs/share.js + +# (OPTIONAL) The directory which contains the pack files. +# Defaults to the theme directory (`app/javascript/themes/[theme]`), +# which should be sufficient for like 99% of use-cases lol. +# pack_directory: app/javascript/packs + +# (OPTIONAL) Additional javascript resources to preload, for use with +# lazy-loaded components. It is **STRONGLY RECOMMENDED** that you +# derive these pathnames from `themes/[your-theme]` to ensure that +# they stay unique. +preload: +- themes/glitch/async/getting_started +- themes/glitch/async/compose +- themes/glitch/async/home_timeline +- themes/glitch/async/notifications diff --git a/app/javascript/themes/glitch/util/api.js b/app/javascript/themes/glitch/util/api.js new file mode 100644 index 000000000..ecc703c0a --- /dev/null +++ b/app/javascript/themes/glitch/util/api.js @@ -0,0 +1,26 @@ +import axios from 'axios'; +import LinkHeader from './link_header'; + +export const getLinks = response => { + const value = response.headers.link; + + if (!value) { + return { refs: [] }; + } + + return LinkHeader.parse(value); +}; + +export default getState => axios.create({ + headers: { + 'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`, + }, + + transformResponse: [function (data) { + try { + return JSON.parse(data); + } catch(Exception) { + return data; + } + }], +}); diff --git a/app/javascript/themes/glitch/util/async-components.js b/app/javascript/themes/glitch/util/async-components.js new file mode 100644 index 000000000..91e85fed5 --- /dev/null +++ b/app/javascript/themes/glitch/util/async-components.js @@ -0,0 +1,115 @@ +export function EmojiPicker () { + return import(/* webpackChunkName: "themes/glitch/async/emoji_picker" */'themes/glitch/util/emoji/emoji_picker'); +} + +export function Compose () { + return import(/* webpackChunkName: "themes/glitch/async/compose" */'themes/glitch/features/compose'); +} + +export function Notifications () { + return import(/* webpackChunkName: "themes/glitch/async/notifications" */'themes/glitch/features/notifications'); +} + +export function HomeTimeline () { + return import(/* webpackChunkName: "themes/glitch/async/home_timeline" */'themes/glitch/features/home_timeline'); +} + +export function PublicTimeline () { + return import(/* webpackChunkName: "themes/glitch/async/public_timeline" */'themes/glitch/features/public_timeline'); +} + +export function CommunityTimeline () { + return import(/* webpackChunkName: "themes/glitch/async/community_timeline" */'themes/glitch/features/community_timeline'); +} + +export function HashtagTimeline () { + return import(/* webpackChunkName: "themes/glitch/async/hashtag_timeline" */'themes/glitch/features/hashtag_timeline'); +} + +export function DirectTimeline() { + return import(/* webpackChunkName: "themes/glitch/async/direct_timeline" */'themes/glitch/features/direct_timeline'); +} + +export function Status () { + return import(/* webpackChunkName: "themes/glitch/async/status" */'themes/glitch/features/status'); +} + +export function GettingStarted () { + return import(/* webpackChunkName: "themes/glitch/async/getting_started" */'themes/glitch/features/getting_started'); +} + +export function PinnedStatuses () { + return import(/* webpackChunkName: "themes/glitch/async/pinned_statuses" */'themes/glitch/features/pinned_statuses'); +} + +export function AccountTimeline () { + return import(/* webpackChunkName: "themes/glitch/async/account_timeline" */'themes/glitch/features/account_timeline'); +} + +export function AccountGallery () { + return import(/* webpackChunkName: "themes/glitch/async/account_gallery" */'themes/glitch/features/account_gallery'); +} + +export function Followers () { + return import(/* webpackChunkName: "themes/glitch/async/followers" */'themes/glitch/features/followers'); +} + +export function Following () { + return import(/* webpackChunkName: "themes/glitch/async/following" */'themes/glitch/features/following'); +} + +export function Reblogs () { + return import(/* webpackChunkName: "themes/glitch/async/reblogs" */'themes/glitch/features/reblogs'); +} + +export function Favourites () { + return import(/* webpackChunkName: "themes/glitch/async/favourites" */'themes/glitch/features/favourites'); +} + +export function FollowRequests () { + return import(/* webpackChunkName: "themes/glitch/async/follow_requests" */'themes/glitch/features/follow_requests'); +} + +export function GenericNotFound () { + return import(/* webpackChunkName: "themes/glitch/async/generic_not_found" */'themes/glitch/features/generic_not_found'); +} + +export function FavouritedStatuses () { + return import(/* webpackChunkName: "themes/glitch/async/favourited_statuses" */'themes/glitch/features/favourited_statuses'); +} + +export function Blocks () { + return import(/* webpackChunkName: "themes/glitch/async/blocks" */'themes/glitch/features/blocks'); +} + +export function Mutes () { + return import(/* webpackChunkName: "themes/glitch/async/mutes" */'themes/glitch/features/mutes'); +} + +export function OnboardingModal () { + return import(/* webpackChunkName: "themes/glitch/async/onboarding_modal" */'themes/glitch/features/ui/components/onboarding_modal'); +} + +export function MuteModal () { + return import(/* webpackChunkName: "themes/glitch/async/mute_modal" */'themes/glitch/features/ui/components/mute_modal'); +} + +export function ReportModal () { + return import(/* webpackChunkName: "themes/glitch/async/report_modal" */'themes/glitch/features/ui/components/report_modal'); +} + +export function SettingsModal () { + return import(/* webpackChunkName: "themes/glitch/async/settings_modal" */'themes/glitch/features/local_settings'); +} + +export function MediaGallery () { + return import(/* webpackChunkName: "themes/glitch/async/media_gallery" */'themes/glitch/components/media_gallery'); +} + +export function Video () { + return import(/* webpackChunkName: "themes/glitch/async/video" */'themes/glitch/features/video'); +} + +export function EmbedModal () { + return import(/* webpackChunkName: "themes/glitch/async/embed_modal" */'themes/glitch/features/ui/components/embed_modal'); +} diff --git a/app/javascript/themes/glitch/util/base_polyfills.js b/app/javascript/themes/glitch/util/base_polyfills.js new file mode 100644 index 000000000..7856b26f9 --- /dev/null +++ b/app/javascript/themes/glitch/util/base_polyfills.js @@ -0,0 +1,18 @@ +import 'intl'; +import 'intl/locale-data/jsonp/en'; +import 'es6-symbol/implement'; +import includes from 'array-includes'; +import assign from 'object-assign'; +import isNaN from 'is-nan'; + +if (!Array.prototype.includes) { + includes.shim(); +} + +if (!Object.assign) { + Object.assign = assign; +} + +if (!Number.isNaN) { + Number.isNaN = isNaN; +} diff --git a/app/javascript/themes/glitch/util/bio_metadata.js b/app/javascript/themes/glitch/util/bio_metadata.js new file mode 100644 index 000000000..599ec20e2 --- /dev/null +++ b/app/javascript/themes/glitch/util/bio_metadata.js @@ -0,0 +1,331 @@ +/* + +`util/bio_metadata` +=================== + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +This file provides two functions for dealing with bio metadata. The +functions are: + + - __`processBio(content)` :__ + Processes `content` to extract any frontmatter. The returned + object has two properties: `text`, which contains the text of + `content` sans-frontmatter, and `metadata`, which is an array + of key-value pairs (in two-element array format). If no + frontmatter was provided in `content`, then `metadata` will be + an empty array. + + - __`createBio(note, data)` :__ + Reverses the process in `processBio()`; takes a `note` and an + array of two-element arrays (which should give keys and values) + and outputs a string containing a well-formed bio with + frontmatter. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/*********************************************************************\ + + To my lovely code maintainers, + + The syntax recognized by the Mastodon frontend for its bio metadata + feature is a subset of that provided by the YAML 1.2 specification. + In particular, Mastodon recognizes metadata which is provided as an + implicit YAML map, where each key-value pair takes up only a single + line (no multi-line values are permitted). To simplify the level of + processing required, Mastodon metadata frontmatter has been limited + to only allow those characters in the `c-printable` set, as defined + by the YAML 1.2 specification, instead of permitting those from the + `nb-json` characters inside double-quoted strings like YAML proper. + ¶ It is important to note that Mastodon only borrows the *syntax* + of YAML, not its semantics. This is to say, Mastodon won't make any + attempt to interpret the data it receives. `true` will not become a + boolean; `56` will not be interpreted as a number. Rather, each key + and every value will be read as a string, and as a string they will + remain. The order of the pairs is unchanged, and any duplicate keys + are preserved. However, YAML escape sequences will be replaced with + the proper interpretations according to the YAML 1.2 specification. + ¶ The implementation provided below interprets `<br>` as `\n` and + allows for an open <p> tag at the beginning of the bio. It replaces + the escaped character entities `'` and `"` with single or + double quotes, respectively, prior to processing. However, no other + escaped characters are replaced, not even those which might have an + impact on the syntax otherwise. These minor allowances are provided + because the Mastodon backend will insert these things automatically + into a bio before sending it through the API, so it is important we + account for them. Aside from this, the YAML frontmatter must be the + very first thing in the bio, leading with three consecutive hyphen- + minues (`---`), and ending with the same or, alternatively, instead + with three periods (`...`). No limits have been set with respect to + the number of characters permitted in the frontmatter, although one + should note that only limited space is provided for them in the UI. + ¶ The regular expression used to check the existence of, and then + process, the YAML frontmatter has been split into a number of small + components in the code below, in the vain hope that it will be much + easier to read and to maintain. I leave it to the future readers of + this code to determine the extent of my successes in this endeavor. + + UPDATE 19 Oct 2017: We no longer allow character escapes inside our + double-quoted strings for ease of processing. We now internally use + the name "ƔAML" in our code to clarify that this is Not Quite YAML. + + Sending love + warmth eternal, + - kibigo [@kibi@glitch.social] + +\*********************************************************************/ + +/* "u" FLAG COMPATABILITY */ + +let compat_mode = false; +try { + new RegExp('.', 'u'); +} catch (e) { + compat_mode = true; +} + +/* CONVENIENCE FUNCTIONS */ + +const unirex = str => compat_mode ? new RegExp(str) : new RegExp(str, 'u'); +const rexstr = exp => '(?:' + exp.source + ')'; + +/* CHARACTER CLASSES */ + +const DOCUMENT_START = /^/; +const DOCUMENT_END = /$/; +const ALLOWED_CHAR = unirex( // `c-printable` in the YAML 1.2 spec. + compat_mode ? '[\t\n\r\x20-\x7e\x85\xa0-\ufffd]' : '[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]' + ); +const WHITE_SPACE = /[ \t]/; +const LINE_BREAK = /\r?\n|\r|<br\s*\/?>/; +const INDICATOR = /[-?:,[\]{}&#*!|>'"%@`]/; +const FLOW_CHAR = /[,[\]{}]/; + +/* NEGATED CHARACTER CLASSES */ + +const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]'); +const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]'); +const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]'); +const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]'); +const NOT_ALLOWED_CHAR = unirex( + '(?!' + rexstr(ALLOWED_CHAR) + ')[^]' +); + +/* BASIC CONSTRUCTS */ + +const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*'); +const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*'); +const NEW_LINE = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) +); +const SOME_NEW_LINES = unirex( + '(?:' + rexstr(NEW_LINE) + ')+' +); +const POSSIBLE_STARTS = unirex( + rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?' +); +const POSSIBLE_ENDS = unirex( + rexstr(SOME_NEW_LINES) + '|' + + rexstr(DOCUMENT_END) + '|' + + rexstr(/<\/p>/) +); +const QUOTE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')[^"]' +); +const ANY_QUOTE_CHAR = unirex( + rexstr(QUOTE_CHAR) + '*' +); + +const ESCAPED_APOS = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/) +); +const ANY_ESCAPED_APOS = unirex( + rexstr(ESCAPED_APOS) + '*' +); +const FIRST_KEY_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +); +const FIRST_VALUE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + // Flow indicators are allowed in values. +); +const LATER_KEY_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); +const LATER_VALUE_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + // Flow indicators are allowed in values. + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); + +/* YAML CONSTRUCTS */ + +const ƔAML_START = unirex( + rexstr(ANY_WHITE_SPACE) + '---' +); +const ƔAML_END = unirex( + rexstr(ANY_WHITE_SPACE) + '(?:---|\.\.\.)' +); +const ƔAML_LOOKAHEAD = unirex( + '(?=' + + rexstr(ƔAML_START) + + rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) + + rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS) + + ')' +); +const ƔAML_DOUBLE_QUOTE = unirex( + '"' + rexstr(ANY_QUOTE_CHAR) + '"' +); +const ƔAML_SINGLE_QUOTE = unirex( + '\'' + rexstr(ANY_ESCAPED_APOS) + '\'' +); +const ƔAML_SIMPLE_KEY = unirex( + rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*' +); +const ƔAML_SIMPLE_VALUE = unirex( + rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*' +); +const ƔAML_KEY = unirex( + rexstr(ƔAML_DOUBLE_QUOTE) + '|' + + rexstr(ƔAML_SINGLE_QUOTE) + '|' + + rexstr(ƔAML_SIMPLE_KEY) +); +const ƔAML_VALUE = unirex( + rexstr(ƔAML_DOUBLE_QUOTE) + '|' + + rexstr(ƔAML_SINGLE_QUOTE) + '|' + + rexstr(ƔAML_SIMPLE_VALUE) +); +const ƔAML_SEPARATOR = unirex( + rexstr(ANY_WHITE_SPACE) + + ':' + rexstr(WHITE_SPACE) + + rexstr(ANY_WHITE_SPACE) +); +const ƔAML_LINE = unirex( + '(' + rexstr(ƔAML_KEY) + ')' + + rexstr(ƔAML_SEPARATOR) + + '(' + rexstr(ƔAML_VALUE) + ')' +); + +/* FRONTMATTER REGEX */ + +const ƔAML_FRONTMATTER = unirex( + rexstr(POSSIBLE_STARTS) + + rexstr(ƔAML_LOOKAHEAD) + + rexstr(ƔAML_START) + rexstr(SOME_NEW_LINES) + + '(?:' + + rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE) + rexstr(SOME_NEW_LINES) + + '){0,5}' + + rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS) +); + +/* SEARCHES */ + +const FIND_ƔAML_LINE = unirex( + rexstr(NEW_LINE) + rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE) +); + +/* STRING PROCESSING */ + +function processString (str) { + switch (str.charAt(0)) { + case '"': + return str.substring(1, str.length - 1); + case '\'': + return str + .substring(1, str.length - 1) + .replace(/''/g, '\''); + default: + return str; + } +} + +/* BIO PROCESSING */ + +export function processBio(content) { + content = content.replace(/"/g, '"').replace(/'/g, '\''); + let result = { + text: content, + metadata: [], + }; + let ɣaml = content.match(ƔAML_FRONTMATTER); + if (!ɣaml) { + return result; + } else { + ɣaml = ɣaml[0]; + } + const start = content.search(ƔAML_START); + const end = start + ɣaml.length - ɣaml.search(ƔAML_START); + result.text = content.substr(end); + let metadata = null; + let query = new RegExp(rexstr(FIND_ƔAML_LINE), 'g'); // Some browsers don't allow flags unless both args are strings + while ((metadata = query.exec(ɣaml))) { + result.metadata.push([ + processString(metadata[1]), + processString(metadata[2]), + ]); + } + return result; +} + +/* BIO CREATION */ + +export function createBio(note, data) { + if (!note) note = ''; + let frontmatter = ''; + if ((data && data.length) || note.match(/^\s*---\s+/)) { + if (!data) frontmatter = '---\n...\n'; + else { + frontmatter += '---\n'; + for (let i = 0; i < data.length; i++) { + let key = '' + data[i][0]; + let val = '' + data[i][1]; + + // Key processing + if (key === (key.match(ƔAML_SIMPLE_KEY) || [])[0]) /* do nothing */; + else if (key === (key.match(ANY_QUOTE_CHAR) || [])[0]) key = '"' + key + '"'; + else { + key = key + .replace(/'/g, '\'\'') + .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�'); + key = '\'' + key + '\''; + } + + // Value processing + if (val === (val.match(ƔAML_SIMPLE_VALUE) || [])[0]) /* do nothing */; + else if (val === (val.match(ANY_QUOTE_CHAR) || [])[0]) val = '"' + val + '"'; + else { + key = key + .replace(/'/g, '\'\'') + .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�'); + key = '\'' + key + '\''; + } + + frontmatter += key + ': ' + val + '\n'; + } + frontmatter += '...\n'; + } + } + return frontmatter + note; +} diff --git a/app/javascript/themes/glitch/util/counter.js b/app/javascript/themes/glitch/util/counter.js new file mode 100644 index 000000000..700ba2163 --- /dev/null +++ b/app/javascript/themes/glitch/util/counter.js @@ -0,0 +1,9 @@ +import { urlRegex } from './url_regex'; + +const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx'; + +export function countableText(inputText) { + return inputText + .replace(urlRegex, urlPlaceholder) + .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3'); +}; diff --git a/app/javascript/themes/glitch/util/emoji/emoji_compressed.js b/app/javascript/themes/glitch/util/emoji/emoji_compressed.js new file mode 100644 index 000000000..e5b834a74 --- /dev/null +++ b/app/javascript/themes/glitch/util/emoji/emoji_compressed.js @@ -0,0 +1,93 @@ +// @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 { default: emojiMartData } = require('emoji-mart/dist/data'); + +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]; + let { 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 = short_names.slice(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/themes/glitch/util/emoji/emoji_map.json b/app/javascript/themes/glitch/util/emoji/emoji_map.json new file mode 100644 index 000000000..13753ba84 --- /dev/null +++ b/app/javascript/themes/glitch/util/emoji/emoji_map.json @@ -0,0 +1 @@ +{"😀":"1f600","😁":"1f601","😂":"1f602","🤣":"1f923","😃":"1f603","😄":"1f604","😅":"1f605","😆":"1f606","😉":"1f609","😊":"1f60a","😋":"1f60b","😎":"1f60e","😍":"1f60d","😘":"1f618","😗":"1f617","😙":"1f619","😚":"1f61a","☺":"263a","🙂":"1f642","🤗":"1f917","🤩":"1f929","🤔":"1f914","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🙄":"1f644","😏":"1f60f","😣":"1f623","😥":"1f625","😮":"1f62e","🤐":"1f910","😯":"1f62f","😪":"1f62a","😫":"1f62b","😴":"1f634","😌":"1f60c","😛":"1f61b","😜":"1f61c","😝":"1f61d","🤤":"1f924","😒":"1f612","😓":"1f613","😔":"1f614","😕":"1f615","🙃":"1f643","🤑":"1f911","😲":"1f632","☹":"2639","🙁":"1f641","😖":"1f616","😞":"1f61e","😟":"1f61f","😤":"1f624","😢":"1f622","😭":"1f62d","😦":"1f626","😧":"1f627","😨":"1f628","😩":"1f629","🤯":"1f92f","😬":"1f62c","😰":"1f630","😱":"1f631","😳":"1f633","🤪":"1f92a","😵":"1f635","😡":"1f621","😠":"1f620","🤬":"1f92c","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","😇":"1f607","🤠":"1f920","🤡":"1f921","🤥":"1f925","🤫":"1f92b","🤭":"1f92d","🧐":"1f9d0","🤓":"1f913","😈":"1f608","👿":"1f47f","👹":"1f479","👺":"1f47a","💀":"1f480","☠":"2620","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","💩":"1f4a9","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👨":"1f468","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🧔":"1f9d4","👱":"1f471","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🙇":"1f647","🤦":"1f926","🤷":"1f937","💆":"1f486","💇":"1f487","🚶":"1f6b6","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","🕴":"1f574","🗣":"1f5e3","👤":"1f464","👥":"1f465","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🏎":"1f3ce","🏍":"1f3cd","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","👫":"1f46b","👬":"1f46c","👭":"1f46d","💏":"1f48f","💑":"1f491","👪":"1f46a","🤳":"1f933","💪":"1f4aa","👈":"1f448","👉":"1f449","☝":"261d","👆":"1f446","🖕":"1f595","👇":"1f447","✌":"270c","🤞":"1f91e","🖖":"1f596","🤘":"1f918","🤙":"1f919","🖐":"1f590","✋":"270b","👌":"1f44c","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","🤚":"1f91a","👋":"1f44b","🤟":"1f91f","✍":"270d","👏":"1f44f","👐":"1f450","🙌":"1f64c","🤲":"1f932","🙏":"1f64f","🤝":"1f91d","💅":"1f485","👂":"1f442","👃":"1f443","👣":"1f463","👀":"1f440","👁":"1f441","🧠":"1f9e0","👅":"1f445","👄":"1f444","💋":"1f48b","💘":"1f498","❤":"2764","💓":"1f493","💔":"1f494","💕":"1f495","💖":"1f496","💗":"1f497","💙":"1f499","💚":"1f49a","💛":"1f49b","🧡":"1f9e1","💜":"1f49c","🖤":"1f5a4","💝":"1f49d","💞":"1f49e","💟":"1f49f","❣":"2763","💌":"1f48c","💤":"1f4a4","💢":"1f4a2","💣":"1f4a3","💥":"1f4a5","💦":"1f4a6","💨":"1f4a8","💫":"1f4ab","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","🕳":"1f573","👓":"1f453","🕶":"1f576","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","👠":"1f460","👡":"1f461","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🐶":"1f436","🐕":"1f415","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦒":"1f992","🐘":"1f418","🦏":"1f98f","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦉":"1f989","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🦀":"1f980","🦐":"1f990","🦑":"1f991","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🐞":"1f41e","🦗":"1f997","🕷":"1f577","🕸":"1f578","🦂":"1f982","💐":"1f490","🌸":"1f338","💮":"1f4ae","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🥝":"1f95d","🍅":"1f345","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🥒":"1f952","🥦":"1f966","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥞":"1f95e","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🥤":"1f964","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🏘":"1f3d8","🏙":"1f3d9","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🌌":"1f30c","🎠":"1f3a0","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🎰":"1f3b0","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🚲":"1f6b2","🛴":"1f6f4","🛵":"1f6f5","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🚧":"1f6a7","🛑":"1f6d1","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","⭐":"2b50","🌟":"1f31f","🌠":"1f320","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🎱":"1f3b1","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","🎯":"1f3af","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎮":"1f3ae","🕹":"1f579","🎲":"1f3b2","♠":"2660","♥":"2665","♦":"2666","♣":"2663","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🥁":"1f941","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","💹":"1f4b9","💱":"1f4b1","💲":"1f4b2","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚗":"2697","⚖":"2696","🔗":"1f517","⛓":"26d3","💉":"1f489","💊":"1f48a","🚬":"1f6ac","⚰":"26b0","⚱":"26b1","🗿":"1f5ff","🛢":"1f6e2","🔮":"1f52e","🛒":"1f6d2","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","✖":"2716","❌":"274c","❎":"274e","➕":"2795","➖":"2796","➗":"2797","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","©":"a9","®":"ae","™":"2122","🔟":"1f51f","💯":"1f4af","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","▪":"25aa","▫":"25ab","◻":"25fb","◼":"25fc","◽":"25fd","◾":"25fe","⬛":"2b1b","⬜":"2b1c","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔲":"1f532","🔳":"1f533","⚪":"26aa","⚫":"26ab","🔴":"1f534","🔵":"1f535","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🗣️":"1f5e3","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🏎️":"1f3ce","🏍️":"1f3cd","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","❤️":"2764","❣️":"2763","🗨️":"1f5e8","🗯️":"1f5ef","🕳️":"1f573","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏙️":"1f3d9","🏚️":"1f3da","⛩️":"26e9","♨️":"2668","🖼️":"1f5bc","🛣️":"1f6e3","🛤️":"1f6e4","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","🛏️":"1f6cf","🛋️":"1f6cb","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚗️":"2697","⚖️":"2696","⛓️":"26d3","⚰️":"26b0","⚱️":"26b1","🛢️":"1f6e2","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","✖️":"2716","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","‼️":"203c","⁉️":"2049","〰️":"3030","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","▪️":"25aa","▫️":"25ab","◻️":"25fb","◼️":"25fc","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","👨⚕":"1f468-200d-2695-fe0f","👩⚕":"1f469-200d-2695-fe0f","👨🎓":"1f468-200d-1f393","👩🎓":"1f469-200d-1f393","👨🏫":"1f468-200d-1f3eb","👩🏫":"1f469-200d-1f3eb","👨⚖":"1f468-200d-2696-fe0f","👩⚖":"1f469-200d-2696-fe0f","👨🌾":"1f468-200d-1f33e","👩🌾":"1f469-200d-1f33e","👨🍳":"1f468-200d-1f373","👩🍳":"1f469-200d-1f373","👨🔧":"1f468-200d-1f527","👩🔧":"1f469-200d-1f527","👨🏭":"1f468-200d-1f3ed","👩🏭":"1f469-200d-1f3ed","👨💼":"1f468-200d-1f4bc","👩💼":"1f469-200d-1f4bc","👨🔬":"1f468-200d-1f52c","👩🔬":"1f469-200d-1f52c","👨💻":"1f468-200d-1f4bb","👩💻":"1f469-200d-1f4bb","👨🎤":"1f468-200d-1f3a4","👩🎤":"1f469-200d-1f3a4","👨🎨":"1f468-200d-1f3a8","👩🎨":"1f469-200d-1f3a8","👨✈":"1f468-200d-2708-fe0f","👩✈":"1f469-200d-2708-fe0f","👨🚀":"1f468-200d-1f680","👩🚀":"1f469-200d-1f680","👨🚒":"1f468-200d-1f692","👩🚒":"1f469-200d-1f692","👮♂":"1f46e-200d-2642-fe0f","👮♀":"1f46e-200d-2640-fe0f","🕵♂":"1f575-fe0f-200d-2642-fe0f","🕵♀":"1f575-fe0f-200d-2640-fe0f","💂♂":"1f482-200d-2642-fe0f","💂♀":"1f482-200d-2640-fe0f","👷♂":"1f477-200d-2642-fe0f","👷♀":"1f477-200d-2640-fe0f","👳♂":"1f473-200d-2642-fe0f","👳♀":"1f473-200d-2640-fe0f","👱♂":"1f471-200d-2642-fe0f","👱♀":"1f471-200d-2640-fe0f","🧙♀":"1f9d9-200d-2640-fe0f","🧙♂":"1f9d9-200d-2642-fe0f","🧚♀":"1f9da-200d-2640-fe0f","🧚♂":"1f9da-200d-2642-fe0f","🧛♀":"1f9db-200d-2640-fe0f","🧛♂":"1f9db-200d-2642-fe0f","🧜♀":"1f9dc-200d-2640-fe0f","🧜♂":"1f9dc-200d-2642-fe0f","🧝♀":"1f9dd-200d-2640-fe0f","🧝♂":"1f9dd-200d-2642-fe0f","🧞♀":"1f9de-200d-2640-fe0f","🧞♂":"1f9de-200d-2642-fe0f","🧟♀":"1f9df-200d-2640-fe0f","🧟♂":"1f9df-200d-2642-fe0f","🙍♂":"1f64d-200d-2642-fe0f","🙍♀":"1f64d-200d-2640-fe0f","🙎♂":"1f64e-200d-2642-fe0f","🙎♀":"1f64e-200d-2640-fe0f","🙅♂":"1f645-200d-2642-fe0f","🙅♀":"1f645-200d-2640-fe0f","🙆♂":"1f646-200d-2642-fe0f","🙆♀":"1f646-200d-2640-fe0f","💁♂":"1f481-200d-2642-fe0f","💁♀":"1f481-200d-2640-fe0f","🙋♂":"1f64b-200d-2642-fe0f","🙋♀":"1f64b-200d-2640-fe0f","🙇♂":"1f647-200d-2642-fe0f","🙇♀":"1f647-200d-2640-fe0f","🤦♂":"1f926-200d-2642-fe0f","🤦♀":"1f926-200d-2640-fe0f","🤷♂":"1f937-200d-2642-fe0f","🤷♀":"1f937-200d-2640-fe0f","💆♂":"1f486-200d-2642-fe0f","💆♀":"1f486-200d-2640-fe0f","💇♂":"1f487-200d-2642-fe0f","💇♀":"1f487-200d-2640-fe0f","🚶♂":"1f6b6-200d-2642-fe0f","🚶♀":"1f6b6-200d-2640-fe0f","🏃♂":"1f3c3-200d-2642-fe0f","🏃♀":"1f3c3-200d-2640-fe0f","👯♂":"1f46f-200d-2642-fe0f","👯♀":"1f46f-200d-2640-fe0f","🧖♀":"1f9d6-200d-2640-fe0f","🧖♂":"1f9d6-200d-2642-fe0f","🧗♀":"1f9d7-200d-2640-fe0f","🧗♂":"1f9d7-200d-2642-fe0f","🧘♀":"1f9d8-200d-2640-fe0f","🧘♂":"1f9d8-200d-2642-fe0f","🏌♂":"1f3cc-fe0f-200d-2642-fe0f","🏌♀":"1f3cc-fe0f-200d-2640-fe0f","🏄♂":"1f3c4-200d-2642-fe0f","🏄♀":"1f3c4-200d-2640-fe0f","🚣♂":"1f6a3-200d-2642-fe0f","🚣♀":"1f6a3-200d-2640-fe0f","🏊♂":"1f3ca-200d-2642-fe0f","🏊♀":"1f3ca-200d-2640-fe0f","⛹♂":"26f9-fe0f-200d-2642-fe0f","⛹♀":"26f9-fe0f-200d-2640-fe0f","🏋♂":"1f3cb-fe0f-200d-2642-fe0f","🏋♀":"1f3cb-fe0f-200d-2640-fe0f","🚴♂":"1f6b4-200d-2642-fe0f","🚴♀":"1f6b4-200d-2640-fe0f","🚵♂":"1f6b5-200d-2642-fe0f","🚵♀":"1f6b5-200d-2640-fe0f","🤸♂":"1f938-200d-2642-fe0f","🤸♀":"1f938-200d-2640-fe0f","🤼♂":"1f93c-200d-2642-fe0f","🤼♀":"1f93c-200d-2640-fe0f","🤽♂":"1f93d-200d-2642-fe0f","🤽♀":"1f93d-200d-2640-fe0f","🤾♂":"1f93e-200d-2642-fe0f","🤾♀":"1f93e-200d-2640-fe0f","🤹♂":"1f939-200d-2642-fe0f","🤹♀":"1f939-200d-2640-fe0f","👨👦":"1f468-200d-1f466","👨👧":"1f468-200d-1f467","👩👦":"1f469-200d-1f466","👩👧":"1f469-200d-1f467","👁🗨":"1f441-200d-1f5e8","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳🌈":"1f3f3-fe0f-200d-1f308","👨⚕️":"1f468-200d-2695-fe0f","👨🏻⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕":"1f468-1f3ff-200d-2695-fe0f","👩⚕️":"1f469-200d-2695-fe0f","👩🏻⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕":"1f469-1f3ff-200d-2695-fe0f","👨🏻🎓":"1f468-1f3fb-200d-1f393","👨🏼🎓":"1f468-1f3fc-200d-1f393","👨🏽🎓":"1f468-1f3fd-200d-1f393","👨🏾🎓":"1f468-1f3fe-200d-1f393","👨🏿🎓":"1f468-1f3ff-200d-1f393","👩🏻🎓":"1f469-1f3fb-200d-1f393","👩🏼🎓":"1f469-1f3fc-200d-1f393","👩🏽🎓":"1f469-1f3fd-200d-1f393","👩🏾🎓":"1f469-1f3fe-200d-1f393","👩🏿🎓":"1f469-1f3ff-200d-1f393","👨🏻🏫":"1f468-1f3fb-200d-1f3eb","👨🏼🏫":"1f468-1f3fc-200d-1f3eb","👨🏽🏫":"1f468-1f3fd-200d-1f3eb","👨🏾🏫":"1f468-1f3fe-200d-1f3eb","👨🏿🏫":"1f468-1f3ff-200d-1f3eb","👩🏻🏫":"1f469-1f3fb-200d-1f3eb","👩🏼🏫":"1f469-1f3fc-200d-1f3eb","👩🏽🏫":"1f469-1f3fd-200d-1f3eb","👩🏾🏫":"1f469-1f3fe-200d-1f3eb","👩🏿🏫":"1f469-1f3ff-200d-1f3eb","👨⚖️":"1f468-200d-2696-fe0f","👨🏻⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖":"1f468-1f3ff-200d-2696-fe0f","👩⚖️":"1f469-200d-2696-fe0f","👩🏻⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖":"1f469-1f3ff-200d-2696-fe0f","👨🏻🌾":"1f468-1f3fb-200d-1f33e","👨🏼🌾":"1f468-1f3fc-200d-1f33e","👨🏽🌾":"1f468-1f3fd-200d-1f33e","👨🏾🌾":"1f468-1f3fe-200d-1f33e","👨🏿🌾":"1f468-1f3ff-200d-1f33e","👩🏻🌾":"1f469-1f3fb-200d-1f33e","👩🏼🌾":"1f469-1f3fc-200d-1f33e","👩🏽🌾":"1f469-1f3fd-200d-1f33e","👩🏾🌾":"1f469-1f3fe-200d-1f33e","👩🏿🌾":"1f469-1f3ff-200d-1f33e","👨🏻🍳":"1f468-1f3fb-200d-1f373","👨🏼🍳":"1f468-1f3fc-200d-1f373","👨🏽🍳":"1f468-1f3fd-200d-1f373","👨🏾🍳":"1f468-1f3fe-200d-1f373","👨🏿🍳":"1f468-1f3ff-200d-1f373","👩🏻🍳":"1f469-1f3fb-200d-1f373","👩🏼🍳":"1f469-1f3fc-200d-1f373","👩🏽🍳":"1f469-1f3fd-200d-1f373","👩🏾🍳":"1f469-1f3fe-200d-1f373","👩🏿🍳":"1f469-1f3ff-200d-1f373","👨🏻🔧":"1f468-1f3fb-200d-1f527","👨🏼🔧":"1f468-1f3fc-200d-1f527","👨🏽🔧":"1f468-1f3fd-200d-1f527","👨🏾🔧":"1f468-1f3fe-200d-1f527","👨🏿🔧":"1f468-1f3ff-200d-1f527","👩🏻🔧":"1f469-1f3fb-200d-1f527","👩🏼🔧":"1f469-1f3fc-200d-1f527","👩🏽🔧":"1f469-1f3fd-200d-1f527","👩🏾🔧":"1f469-1f3fe-200d-1f527","👩🏿🔧":"1f469-1f3ff-200d-1f527","👨🏻🏭":"1f468-1f3fb-200d-1f3ed","👨🏼🏭":"1f468-1f3fc-200d-1f3ed","👨🏽🏭":"1f468-1f3fd-200d-1f3ed","👨🏾🏭":"1f468-1f3fe-200d-1f3ed","👨🏿🏭":"1f468-1f3ff-200d-1f3ed","👩🏻🏭":"1f469-1f3fb-200d-1f3ed","👩🏼🏭":"1f469-1f3fc-200d-1f3ed","👩🏽🏭":"1f469-1f3fd-200d-1f3ed","👩🏾🏭":"1f469-1f3fe-200d-1f3ed","👩🏿🏭":"1f469-1f3ff-200d-1f3ed","👨🏻💼":"1f468-1f3fb-200d-1f4bc","👨🏼💼":"1f468-1f3fc-200d-1f4bc","👨🏽💼":"1f468-1f3fd-200d-1f4bc","👨🏾💼":"1f468-1f3fe-200d-1f4bc","👨🏿💼":"1f468-1f3ff-200d-1f4bc","👩🏻💼":"1f469-1f3fb-200d-1f4bc","👩🏼💼":"1f469-1f3fc-200d-1f4bc","👩🏽💼":"1f469-1f3fd-200d-1f4bc","👩🏾💼":"1f469-1f3fe-200d-1f4bc","👩🏿💼":"1f469-1f3ff-200d-1f4bc","👨🏻🔬":"1f468-1f3fb-200d-1f52c","👨🏼🔬":"1f468-1f3fc-200d-1f52c","👨🏽🔬":"1f468-1f3fd-200d-1f52c","👨🏾🔬":"1f468-1f3fe-200d-1f52c","👨🏿🔬":"1f468-1f3ff-200d-1f52c","👩🏻🔬":"1f469-1f3fb-200d-1f52c","👩🏼🔬":"1f469-1f3fc-200d-1f52c","👩🏽🔬":"1f469-1f3fd-200d-1f52c","👩🏾🔬":"1f469-1f3fe-200d-1f52c","👩🏿🔬":"1f469-1f3ff-200d-1f52c","👨🏻💻":"1f468-1f3fb-200d-1f4bb","👨🏼💻":"1f468-1f3fc-200d-1f4bb","👨🏽💻":"1f468-1f3fd-200d-1f4bb","👨🏾💻":"1f468-1f3fe-200d-1f4bb","👨🏿💻":"1f468-1f3ff-200d-1f4bb","👩🏻💻":"1f469-1f3fb-200d-1f4bb","👩🏼💻":"1f469-1f3fc-200d-1f4bb","👩🏽💻":"1f469-1f3fd-200d-1f4bb","👩🏾💻":"1f469-1f3fe-200d-1f4bb","👩🏿💻":"1f469-1f3ff-200d-1f4bb","👨🏻🎤":"1f468-1f3fb-200d-1f3a4","👨🏼🎤":"1f468-1f3fc-200d-1f3a4","👨🏽🎤":"1f468-1f3fd-200d-1f3a4","👨🏾🎤":"1f468-1f3fe-200d-1f3a4","👨🏿🎤":"1f468-1f3ff-200d-1f3a4","👩🏻🎤":"1f469-1f3fb-200d-1f3a4","👩🏼🎤":"1f469-1f3fc-200d-1f3a4","👩🏽🎤":"1f469-1f3fd-200d-1f3a4","👩🏾🎤":"1f469-1f3fe-200d-1f3a4","👩🏿🎤":"1f469-1f3ff-200d-1f3a4","👨🏻🎨":"1f468-1f3fb-200d-1f3a8","👨🏼🎨":"1f468-1f3fc-200d-1f3a8","👨🏽🎨":"1f468-1f3fd-200d-1f3a8","👨🏾🎨":"1f468-1f3fe-200d-1f3a8","👨🏿🎨":"1f468-1f3ff-200d-1f3a8","👩🏻🎨":"1f469-1f3fb-200d-1f3a8","👩🏼🎨":"1f469-1f3fc-200d-1f3a8","👩🏽🎨":"1f469-1f3fd-200d-1f3a8","👩🏾🎨":"1f469-1f3fe-200d-1f3a8","👩🏿🎨":"1f469-1f3ff-200d-1f3a8","👨✈️":"1f468-200d-2708-fe0f","👨🏻✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈":"1f468-1f3ff-200d-2708-fe0f","👩✈️":"1f469-200d-2708-fe0f","👩🏻✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈":"1f469-1f3ff-200d-2708-fe0f","👨🏻🚀":"1f468-1f3fb-200d-1f680","👨🏼🚀":"1f468-1f3fc-200d-1f680","👨🏽🚀":"1f468-1f3fd-200d-1f680","👨🏾🚀":"1f468-1f3fe-200d-1f680","👨🏿🚀":"1f468-1f3ff-200d-1f680","👩🏻🚀":"1f469-1f3fb-200d-1f680","👩🏼🚀":"1f469-1f3fc-200d-1f680","👩🏽🚀":"1f469-1f3fd-200d-1f680","👩🏾🚀":"1f469-1f3fe-200d-1f680","👩🏿🚀":"1f469-1f3ff-200d-1f680","👨🏻🚒":"1f468-1f3fb-200d-1f692","👨🏼🚒":"1f468-1f3fc-200d-1f692","👨🏽🚒":"1f468-1f3fd-200d-1f692","👨🏾🚒":"1f468-1f3fe-200d-1f692","👨🏿🚒":"1f468-1f3ff-200d-1f692","👩🏻🚒":"1f469-1f3fb-200d-1f692","👩🏼🚒":"1f469-1f3fc-200d-1f692","👩🏽🚒":"1f469-1f3fd-200d-1f692","👩🏾🚒":"1f469-1f3fe-200d-1f692","👩🏿🚒":"1f469-1f3ff-200d-1f692","👮♂️":"1f46e-200d-2642-fe0f","👮🏻♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂":"1f46e-1f3ff-200d-2642-fe0f","👮♀️":"1f46e-200d-2640-fe0f","👮🏻♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀":"1f46e-1f3ff-200d-2640-fe0f","🕵♂️":"1f575-fe0f-200d-2642-fe0f","🕵️♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂":"1f575-1f3ff-200d-2642-fe0f","🕵♀️":"1f575-fe0f-200d-2640-fe0f","🕵️♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀":"1f575-1f3ff-200d-2640-fe0f","💂♂️":"1f482-200d-2642-fe0f","💂🏻♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂":"1f482-1f3ff-200d-2642-fe0f","💂♀️":"1f482-200d-2640-fe0f","💂🏻♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀":"1f482-1f3ff-200d-2640-fe0f","👷♂️":"1f477-200d-2642-fe0f","👷🏻♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂":"1f477-1f3ff-200d-2642-fe0f","👷♀️":"1f477-200d-2640-fe0f","👷🏻♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀":"1f477-1f3ff-200d-2640-fe0f","👳♂️":"1f473-200d-2642-fe0f","👳🏻♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂":"1f473-1f3ff-200d-2642-fe0f","👳♀️":"1f473-200d-2640-fe0f","👳🏻♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀":"1f473-1f3ff-200d-2640-fe0f","👱♂️":"1f471-200d-2642-fe0f","👱🏻♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂":"1f471-1f3ff-200d-2642-fe0f","👱♀️":"1f471-200d-2640-fe0f","👱🏻♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀":"1f471-1f3ff-200d-2640-fe0f","🧙♀️":"1f9d9-200d-2640-fe0f","🧙🏻♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀":"1f9d9-1f3ff-200d-2640-fe0f","🧙♂️":"1f9d9-200d-2642-fe0f","🧙🏻♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂":"1f9d9-1f3ff-200d-2642-fe0f","🧚♀️":"1f9da-200d-2640-fe0f","🧚🏻♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀":"1f9da-1f3ff-200d-2640-fe0f","🧚♂️":"1f9da-200d-2642-fe0f","🧚🏻♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂":"1f9da-1f3ff-200d-2642-fe0f","🧛♀️":"1f9db-200d-2640-fe0f","🧛🏻♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀":"1f9db-1f3ff-200d-2640-fe0f","🧛♂️":"1f9db-200d-2642-fe0f","🧛🏻♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂":"1f9db-1f3ff-200d-2642-fe0f","🧜♀️":"1f9dc-200d-2640-fe0f","🧜🏻♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀":"1f9dc-1f3ff-200d-2640-fe0f","🧜♂️":"1f9dc-200d-2642-fe0f","🧜🏻♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂":"1f9dc-1f3ff-200d-2642-fe0f","🧝♀️":"1f9dd-200d-2640-fe0f","🧝🏻♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀":"1f9dd-1f3ff-200d-2640-fe0f","🧝♂️":"1f9dd-200d-2642-fe0f","🧝🏻♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂":"1f9dd-1f3ff-200d-2642-fe0f","🧞♀️":"1f9de-200d-2640-fe0f","🧞♂️":"1f9de-200d-2642-fe0f","🧟♀️":"1f9df-200d-2640-fe0f","🧟♂️":"1f9df-200d-2642-fe0f","🙍♂️":"1f64d-200d-2642-fe0f","🙍🏻♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂":"1f64d-1f3ff-200d-2642-fe0f","🙍♀️":"1f64d-200d-2640-fe0f","🙍🏻♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀":"1f64d-1f3ff-200d-2640-fe0f","🙎♂️":"1f64e-200d-2642-fe0f","🙎🏻♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂":"1f64e-1f3ff-200d-2642-fe0f","🙎♀️":"1f64e-200d-2640-fe0f","🙎🏻♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀":"1f64e-1f3ff-200d-2640-fe0f","🙅♂️":"1f645-200d-2642-fe0f","🙅🏻♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂":"1f645-1f3ff-200d-2642-fe0f","🙅♀️":"1f645-200d-2640-fe0f","🙅🏻♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀":"1f645-1f3ff-200d-2640-fe0f","🙆♂️":"1f646-200d-2642-fe0f","🙆🏻♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂":"1f646-1f3ff-200d-2642-fe0f","🙆♀️":"1f646-200d-2640-fe0f","🙆🏻♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀":"1f646-1f3ff-200d-2640-fe0f","💁♂️":"1f481-200d-2642-fe0f","💁🏻♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂":"1f481-1f3ff-200d-2642-fe0f","💁♀️":"1f481-200d-2640-fe0f","💁🏻♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀":"1f481-1f3ff-200d-2640-fe0f","🙋♂️":"1f64b-200d-2642-fe0f","🙋🏻♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂":"1f64b-1f3ff-200d-2642-fe0f","🙋♀️":"1f64b-200d-2640-fe0f","🙋🏻♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀":"1f64b-1f3ff-200d-2640-fe0f","🙇♂️":"1f647-200d-2642-fe0f","🙇🏻♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂":"1f647-1f3ff-200d-2642-fe0f","🙇♀️":"1f647-200d-2640-fe0f","🙇🏻♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀":"1f647-1f3ff-200d-2640-fe0f","🤦♂️":"1f926-200d-2642-fe0f","🤦🏻♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂":"1f926-1f3ff-200d-2642-fe0f","🤦♀️":"1f926-200d-2640-fe0f","🤦🏻♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀":"1f926-1f3ff-200d-2640-fe0f","🤷♂️":"1f937-200d-2642-fe0f","🤷🏻♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂":"1f937-1f3ff-200d-2642-fe0f","🤷♀️":"1f937-200d-2640-fe0f","🤷🏻♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀":"1f937-1f3ff-200d-2640-fe0f","💆♂️":"1f486-200d-2642-fe0f","💆🏻♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂":"1f486-1f3ff-200d-2642-fe0f","💆♀️":"1f486-200d-2640-fe0f","💆🏻♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀":"1f486-1f3ff-200d-2640-fe0f","💇♂️":"1f487-200d-2642-fe0f","💇🏻♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂":"1f487-1f3ff-200d-2642-fe0f","💇♀️":"1f487-200d-2640-fe0f","💇🏻♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀":"1f487-1f3ff-200d-2640-fe0f","🚶♂️":"1f6b6-200d-2642-fe0f","🚶🏻♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶♀️":"1f6b6-200d-2640-fe0f","🚶🏻♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀":"1f6b6-1f3ff-200d-2640-fe0f","🏃♂️":"1f3c3-200d-2642-fe0f","🏃🏻♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃♀️":"1f3c3-200d-2640-fe0f","🏃🏻♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀":"1f3c3-1f3ff-200d-2640-fe0f","👯♂️":"1f46f-200d-2642-fe0f","👯♀️":"1f46f-200d-2640-fe0f","🧖♀️":"1f9d6-200d-2640-fe0f","🧖🏻♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀":"1f9d6-1f3ff-200d-2640-fe0f","🧖♂️":"1f9d6-200d-2642-fe0f","🧖🏻♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂":"1f9d6-1f3ff-200d-2642-fe0f","🧗♀️":"1f9d7-200d-2640-fe0f","🧗🏻♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀":"1f9d7-1f3ff-200d-2640-fe0f","🧗♂️":"1f9d7-200d-2642-fe0f","🧗🏻♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂":"1f9d7-1f3ff-200d-2642-fe0f","🧘♀️":"1f9d8-200d-2640-fe0f","🧘🏻♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀":"1f9d8-1f3ff-200d-2640-fe0f","🧘♂️":"1f9d8-200d-2642-fe0f","🧘🏻♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂":"1f9d8-1f3ff-200d-2642-fe0f","🏌♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄♂️":"1f3c4-200d-2642-fe0f","🏄🏻♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄♀️":"1f3c4-200d-2640-fe0f","🏄🏻♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣♂️":"1f6a3-200d-2642-fe0f","🚣🏻♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣♀️":"1f6a3-200d-2640-fe0f","🚣🏻♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊♂️":"1f3ca-200d-2642-fe0f","🏊🏻♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊♀️":"1f3ca-200d-2640-fe0f","🏊🏻♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹♂️":"26f9-fe0f-200d-2642-fe0f","⛹️♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂":"26f9-1f3ff-200d-2642-fe0f","⛹♀️":"26f9-fe0f-200d-2640-fe0f","⛹️♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀":"26f9-1f3ff-200d-2640-fe0f","🏋♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴♂️":"1f6b4-200d-2642-fe0f","🚴🏻♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴♀️":"1f6b4-200d-2640-fe0f","🚴🏻♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵♂️":"1f6b5-200d-2642-fe0f","🚵🏻♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵♀️":"1f6b5-200d-2640-fe0f","🚵🏻♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸♂️":"1f938-200d-2642-fe0f","🤸🏻♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂":"1f938-1f3ff-200d-2642-fe0f","🤸♀️":"1f938-200d-2640-fe0f","🤸🏻♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀":"1f938-1f3ff-200d-2640-fe0f","🤼♂️":"1f93c-200d-2642-fe0f","🤼♀️":"1f93c-200d-2640-fe0f","🤽♂️":"1f93d-200d-2642-fe0f","🤽🏻♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂":"1f93d-1f3ff-200d-2642-fe0f","🤽♀️":"1f93d-200d-2640-fe0f","🤽🏻♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀":"1f93d-1f3ff-200d-2640-fe0f","🤾♂️":"1f93e-200d-2642-fe0f","🤾🏻♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂":"1f93e-1f3ff-200d-2642-fe0f","🤾♀️":"1f93e-200d-2640-fe0f","🤾🏻♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀":"1f93e-1f3ff-200d-2640-fe0f","🤹♂️":"1f939-200d-2642-fe0f","🤹🏻♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂":"1f939-1f3ff-200d-2642-fe0f","🤹♀️":"1f939-200d-2640-fe0f","🤹🏻♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀":"1f939-1f3ff-200d-2640-fe0f","👁🗨️":"1f441-200d-1f5e8","👁️🗨":"1f441-200d-1f5e8","🏳️🌈":"1f3f3-fe0f-200d-1f308","👨🏻⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕️":"1f469-1f3ff-200d-2695-fe0f","👨🏻⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖️":"1f469-1f3ff-200d-2696-fe0f","👨🏻✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀️":"1f473-1f3ff-200d-2640-fe0f","👱🏻♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂️":"1f471-1f3ff-200d-2642-fe0f","👱🏻♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀️":"1f471-1f3ff-200d-2640-fe0f","🧙🏻♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧙🏻♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧚🏻♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀️":"1f9da-1f3ff-200d-2640-fe0f","🧚🏻♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂️":"1f9da-1f3ff-200d-2642-fe0f","🧛🏻♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀️":"1f9db-1f3ff-200d-2640-fe0f","🧛🏻♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂️":"1f9db-1f3ff-200d-2642-fe0f","🧜🏻♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧜🏻♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧝🏻♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀️":"1f9dd-1f3ff-200d-2640-fe0f","🧝🏻♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂️":"1f9dd-1f3ff-200d-2642-fe0f","🙍🏻♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀️":"1f64b-1f3ff-200d-2640-fe0f","🙇🏻♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀️":"1f937-1f3ff-200d-2640-fe0f","💆🏻♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀️":"1f6b6-1f3ff-200d-2640-fe0f","🏃🏻♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧖🏻♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧗🏻♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀️":"1f9d7-1f3ff-200d-2640-fe0f","🧗🏻♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧘🏻♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧘🏻♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂️":"1f9d8-1f3ff-200d-2642-fe0f","🏌️♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀️":"1f939-1f3ff-200d-2640-fe0f","👩❤👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤👩":"1f469-200d-2764-fe0f-200d-1f469","👨👩👦":"1f468-200d-1f469-200d-1f466","👨👩👧":"1f468-200d-1f469-200d-1f467","👨👨👦":"1f468-200d-1f468-200d-1f466","👨👨👧":"1f468-200d-1f468-200d-1f467","👩👩👦":"1f469-200d-1f469-200d-1f466","👩👩👧":"1f469-200d-1f469-200d-1f467","👨👦👦":"1f468-200d-1f466-200d-1f466","👨👧👦":"1f468-200d-1f467-200d-1f466","👨👧👧":"1f468-200d-1f467-200d-1f467","👩👦👦":"1f469-200d-1f466-200d-1f466","👩👧👦":"1f469-200d-1f467-200d-1f466","👩👧👧":"1f469-200d-1f467-200d-1f467","👁️🗨️":"1f441-200d-1f5e8","👩❤️👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤️👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤️👩":"1f469-200d-2764-fe0f-200d-1f469","👩❤💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","👨👩👧👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨👩👦👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨👩👧👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨👨👧👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨👨👦👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨👨👧👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩👩👧👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩👩👦👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩👩👧👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩❤️💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤️💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤️💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469"} \ No newline at end of file diff --git a/app/javascript/themes/glitch/util/emoji/emoji_mart_data_light.js b/app/javascript/themes/glitch/util/emoji/emoji_mart_data_light.js new file mode 100644 index 000000000..45086fc4c --- /dev/null +++ b/app/javascript/themes/glitch/util/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/themes/glitch/util/emoji/emoji_mart_search_light.js b/app/javascript/themes/glitch/util/emoji/emoji_mart_search_light.js new file mode 100644 index 000000000..5755bf1c4 --- /dev/null +++ b/app/javascript/themes/glitch/util/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/themes/glitch/util/emoji/emoji_picker.js b/app/javascript/themes/glitch/util/emoji/emoji_picker.js new file mode 100644 index 000000000..7e145381e --- /dev/null +++ b/app/javascript/themes/glitch/util/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/themes/glitch/util/emoji/emoji_unicode_mapping_light.js b/app/javascript/themes/glitch/util/emoji/emoji_unicode_mapping_light.js new file mode 100644 index 000000000..918684c31 --- /dev/null +++ b/app/javascript/themes/glitch/util/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/themes/glitch/util/emoji/emoji_utils.js b/app/javascript/themes/glitch/util/emoji/emoji_utils.js new file mode 100644 index 000000000..dbf725c1f --- /dev/null +++ b/app/javascript/themes/glitch/util/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/themes/glitch/util/emoji/index.js b/app/javascript/themes/glitch/util/emoji/index.js new file mode 100644 index 000000000..8c45a58fe --- /dev/null +++ b/app/javascript/themes/glitch/util/emoji/index.js @@ -0,0 +1,95 @@ +import { autoPlayGif } from 'themes/glitch/util/initial_state'; +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 || ''; + +const emojify = (str, customEmojis = {}) => { + const tagCharsWithoutEmojis = '<&'; + const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&'; + let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0; + for (;;) { + let match, i = 0, tag; + while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(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 = autoPlayGif ? 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; + } + if (tag === 0) { + if (invisible) { + if (str[i + 1] === '/') { // closing tag + if (!--invisible) { + tagChars = tagCharsWithEmojis; + } + } else if (str[rend - 2] !== '/') { // opening tag + invisible++; + } + } else { + if (str.startsWith('<span class="invisible">', i)) { + // avoid emojifying on invisible text + invisible = 1; + tagChars = tagCharsWithoutEmojis; + } + } + } + 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) => { + const emojis = []; + + customEmojis.forEach(emoji => { + const shortcode = emoji.get('shortcode'); + const url = autoPlayGif ? 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/themes/glitch/util/emoji/unicode_to_filename.js b/app/javascript/themes/glitch/util/emoji/unicode_to_filename.js new file mode 100644 index 000000000..c75c4cd7d --- /dev/null +++ b/app/javascript/themes/glitch/util/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/themes/glitch/util/emoji/unicode_to_unified_name.js b/app/javascript/themes/glitch/util/emoji/unicode_to_unified_name.js new file mode 100644 index 000000000..808ac197e --- /dev/null +++ b/app/javascript/themes/glitch/util/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/themes/glitch/util/extra_polyfills.js b/app/javascript/themes/glitch/util/extra_polyfills.js new file mode 100644 index 000000000..3acc55abd --- /dev/null +++ b/app/javascript/themes/glitch/util/extra_polyfills.js @@ -0,0 +1,5 @@ +import 'intersection-observer'; +import 'requestidlecallback'; +import objectFitImages from 'object-fit-images'; + +objectFitImages(); diff --git a/app/javascript/themes/glitch/util/fullscreen.js b/app/javascript/themes/glitch/util/fullscreen.js new file mode 100644 index 000000000..cf5d0cf98 --- /dev/null +++ b/app/javascript/themes/glitch/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/themes/glitch/util/get_rect_from_entry.js b/app/javascript/themes/glitch/util/get_rect_from_entry.js new file mode 100644 index 000000000..c266cd7dc --- /dev/null +++ b/app/javascript/themes/glitch/util/get_rect_from_entry.js @@ -0,0 +1,21 @@ + +// Get the bounding client rect from an IntersectionObserver entry. +// This is to work around a bug in Chrome: https://crbug.com/737228 + +let hasBoundingRectBug; + +function getRectFromEntry(entry) { + if (typeof hasBoundingRectBug !== 'boolean') { + const boundingRect = entry.target.getBoundingClientRect(); + const observerRect = entry.boundingClientRect; + hasBoundingRectBug = boundingRect.height !== observerRect.height || + boundingRect.top !== observerRect.top || + boundingRect.width !== observerRect.width || + boundingRect.bottom !== observerRect.bottom || + boundingRect.left !== observerRect.left || + boundingRect.right !== observerRect.right; + } + return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect; +} + +export default getRectFromEntry; diff --git a/app/javascript/themes/glitch/util/initial_state.js b/app/javascript/themes/glitch/util/initial_state.js new file mode 100644 index 000000000..ef5d8b0ef --- /dev/null +++ b/app/javascript/themes/glitch/util/initial_state.js @@ -0,0 +1,21 @@ +const element = document.getElementById('initial-state'); +const initialState = element && function () { + const result = JSON.parse(element.textContent); + try { + result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings')); + } catch (e) { + result.local_settings = {}; + } + return result; +}(); + +const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop]; + +export const reduceMotion = getMeta('reduce_motion'); +export const autoPlayGif = getMeta('auto_play_gif'); +export const unfollowModal = getMeta('unfollow_modal'); +export const boostModal = getMeta('boost_modal'); +export const deleteModal = getMeta('delete_modal'); +export const me = getMeta('me'); + +export default initialState; diff --git a/app/javascript/themes/glitch/util/intersection_observer_wrapper.js b/app/javascript/themes/glitch/util/intersection_observer_wrapper.js new file mode 100644 index 000000000..2b24c6583 --- /dev/null +++ b/app/javascript/themes/glitch/util/intersection_observer_wrapper.js @@ -0,0 +1,57 @@ +// Wrapper for IntersectionObserver in order to make working with it +// a bit easier. We also follow this performance advice: +// "If you need to observe multiple elements, it is both possible and +// advised to observe multiple elements using the same IntersectionObserver +// instance by calling observe() multiple times." +// https://developers.google.com/web/updates/2016/04/intersectionobserver + +class IntersectionObserverWrapper { + + callbacks = {}; + observerBacklog = []; + observer = null; + + connect (options) { + const onIntersection = (entries) => { + entries.forEach(entry => { + const id = entry.target.getAttribute('data-id'); + if (this.callbacks[id]) { + this.callbacks[id](entry); + } + }); + }; + + this.observer = new IntersectionObserver(onIntersection, options); + this.observerBacklog.forEach(([ id, node, callback ]) => { + this.observe(id, node, callback); + }); + this.observerBacklog = null; + } + + observe (id, node, callback) { + if (!this.observer) { + this.observerBacklog.push([ id, node, callback ]); + } else { + this.callbacks[id] = callback; + this.observer.observe(node); + } + } + + unobserve (id, node) { + if (this.observer) { + delete this.callbacks[id]; + this.observer.unobserve(node); + } + } + + disconnect () { + if (this.observer) { + this.callbacks = {}; + this.observer.disconnect(); + this.observer = null; + } + } + +} + +export default IntersectionObserverWrapper; diff --git a/app/javascript/themes/glitch/util/is_mobile.js b/app/javascript/themes/glitch/util/is_mobile.js new file mode 100644 index 000000000..80e8e0a8a --- /dev/null +++ b/app/javascript/themes/glitch/util/is_mobile.js @@ -0,0 +1,34 @@ +import detectPassiveEvents from 'detect-passive-events'; + +const LAYOUT_BREAKPOINT = 630; + +export function isMobile(width, columns) { + switch (columns) { + case 'multiple': + return false; + case 'single': + return true; + default: + return width <= LAYOUT_BREAKPOINT; + } +}; + +const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + +let userTouching = false; +let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +function touchListener() { + userTouching = true; + window.removeEventListener('touchstart', touchListener, listenerOptions); +} + +window.addEventListener('touchstart', touchListener, listenerOptions); + +export function isUserTouching() { + return userTouching; +} + +export function isIOS() { + return iOS; +}; diff --git a/app/javascript/themes/glitch/util/link_header.js b/app/javascript/themes/glitch/util/link_header.js new file mode 100644 index 000000000..a3e7ccf1c --- /dev/null +++ b/app/javascript/themes/glitch/util/link_header.js @@ -0,0 +1,33 @@ +import Link from 'http-link-header'; +import querystring from 'querystring'; + +Link.parseAttrs = (link, parts) => { + let match = null; + let attr = ''; + let value = ''; + let attrs = ''; + + let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts); + + if(uriAttrs) { + attrs = uriAttrs[2]; + link = Link.parseParams(link, uriAttrs[1]); + } + + while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign + attr = match[1].toLowerCase(); + value = match[4] || match[3] || match[2]; + + if( /\*$/.test(attr)) { + Link.setAttr(link, attr, Link.parseExtendedValue(value)); + } else if(/%/.test(value)) { + Link.setAttr(link, attr, querystring.decode(value)); + } else { + Link.setAttr(link, attr, value); + } + } + + return link; +}; + +export default Link; diff --git a/app/javascript/themes/glitch/util/load_polyfills.js b/app/javascript/themes/glitch/util/load_polyfills.js new file mode 100644 index 000000000..8927b7358 --- /dev/null +++ b/app/javascript/themes/glitch/util/load_polyfills.js @@ -0,0 +1,39 @@ +// Convenience function to load polyfills and return a promise when it's done. +// If there are no polyfills, then this is just Promise.resolve() which means +// it will execute in the same tick of the event loop (i.e. near-instant). + +function importBasePolyfills() { + return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills'); +} + +function importExtraPolyfills() { + return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills'); +} + +function loadPolyfills() { + const needsBasePolyfills = !( + window.Intl && + Object.assign && + Number.isNaN && + window.Symbol && + Array.prototype.includes + ); + + // Latest version of Firefox and Safari do not have IntersectionObserver. + // Edge does not have requestIdleCallback and object-fit CSS property. + // This avoids shipping them all the polyfills. + const needsExtraPolyfills = !( + window.IntersectionObserver && + window.IntersectionObserverEntry && + 'isIntersecting' in IntersectionObserverEntry.prototype && + window.requestIdleCallback && + 'object-fit' in (new Image()).style + ); + + return Promise.all([ + needsBasePolyfills && importBasePolyfills(), + needsExtraPolyfills && importExtraPolyfills(), + ]); +} + +export default loadPolyfills; diff --git a/app/javascript/themes/glitch/util/main.js b/app/javascript/themes/glitch/util/main.js new file mode 100644 index 000000000..c10a64ded --- /dev/null +++ b/app/javascript/themes/glitch/util/main.js @@ -0,0 +1,39 @@ +import * as WebPushSubscription from './web_push_subscription'; +import Mastodon from 'themes/glitch/containers/mastodon'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import ready from './ready'; + +const perf = require('./performance'); + +function main() { + perf.start('main()'); + + if (window.history && history.replaceState) { + const { pathname, search, hash } = window.location; + const path = pathname + search + hash; + if (!(/^\/web[$/]/).test(path)) { + history.replaceState(null, document.title, `/web${path}`); + } + } + + ready(() => { + const mountNode = document.getElementById('mastodon'); + const props = JSON.parse(mountNode.getAttribute('data-props')); + + ReactDOM.render(<Mastodon {...props} />, mountNode); + if (process.env.NODE_ENV === 'production') { + // avoid offline in dev mode because it's harder to debug + require('offline-plugin/runtime').install(); + WebPushSubscription.register(); + } + perf.stop('main()'); + + // remember the initial URL + if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') { + window._mastoInitialHistoryLen = window.history.length; + } + }); +} + +export default main; diff --git a/app/javascript/themes/glitch/util/optional_motion.js b/app/javascript/themes/glitch/util/optional_motion.js new file mode 100644 index 000000000..b8a57b22f --- /dev/null +++ b/app/javascript/themes/glitch/util/optional_motion.js @@ -0,0 +1,5 @@ +import { reduceMotion } from 'themes/glitch/util/initial_state'; +import ReducedMotion from './reduced_motion'; +import Motion from 'react-motion/lib/Motion'; + +export default reduceMotion ? ReducedMotion : Motion; diff --git a/app/javascript/themes/glitch/util/performance.js b/app/javascript/themes/glitch/util/performance.js new file mode 100644 index 000000000..450a90626 --- /dev/null +++ b/app/javascript/themes/glitch/util/performance.js @@ -0,0 +1,31 @@ +// +// Tools for performance debugging, only enabled in development mode. +// Open up Chrome Dev Tools, then Timeline, then User Timing to see output. +// Also see config/webpack/loaders/mark.js for the webpack loader marks. +// + +let marky; + +if (process.env.NODE_ENV === 'development') { + if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) { + // Increase Firefox's performance entry limit; otherwise it's capped to 150. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135 + performance.setResourceTimingBufferSize(Infinity); + } + marky = require('marky'); + // allows us to easily do e.g. ReactPerf.printWasted() while debugging + //window.ReactPerf = require('react-addons-perf'); + //window.ReactPerf.start(); +} + +export function start(name) { + if (process.env.NODE_ENV === 'development') { + marky.mark(name); + } +} + +export function stop(name) { + if (process.env.NODE_ENV === 'development') { + marky.stop(name); + } +} diff --git a/app/javascript/themes/glitch/util/react_router_helpers.js b/app/javascript/themes/glitch/util/react_router_helpers.js new file mode 100644 index 000000000..c02fb5247 --- /dev/null +++ b/app/javascript/themes/glitch/util/react_router_helpers.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Switch, Route } from 'react-router-dom'; + +import ColumnLoading from 'themes/glitch/features/ui/components/column_loading'; +import BundleColumnError from 'themes/glitch/features/ui/components/bundle_column_error'; +import BundleContainer from 'themes/glitch/features/ui/containers/bundle_container'; + +// Small wrapper to pass multiColumn to the route components +export class WrappedSwitch extends React.PureComponent { + + render () { + const { multiColumn, children } = this.props; + + return ( + <Switch> + {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} + </Switch> + ); + } + +} + +WrappedSwitch.propTypes = { + multiColumn: PropTypes.bool, + children: PropTypes.node, +}; + +// Small Wraper to extract the params from the route and pass +// them to the rendered component, together with the content to +// be rendered inside (the children) +export class WrappedRoute extends React.Component { + + static propTypes = { + component: PropTypes.func.isRequired, + content: PropTypes.node, + multiColumn: PropTypes.bool, + } + + renderComponent = ({ match }) => { + const { component, content, multiColumn } = this.props; + + return ( + <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> + {Component => <Component params={match.params} multiColumn={multiColumn}>{content}</Component>} + </BundleContainer> + ); + } + + renderLoading = () => { + return <ColumnLoading />; + } + + renderError = (props) => { + return <BundleColumnError {...props} />; + } + + render () { + const { component: Component, content, ...rest } = this.props; + + return <Route {...rest} render={this.renderComponent} />; + } + +} diff --git a/app/javascript/themes/glitch/util/ready.js b/app/javascript/themes/glitch/util/ready.js new file mode 100644 index 000000000..dd543910b --- /dev/null +++ b/app/javascript/themes/glitch/util/ready.js @@ -0,0 +1,7 @@ +export default function ready(loaded) { + if (['interactive', 'complete'].includes(document.readyState)) { + loaded(); + } else { + document.addEventListener('DOMContentLoaded', loaded); + } +} diff --git a/app/javascript/themes/glitch/util/reduced_motion.js b/app/javascript/themes/glitch/util/reduced_motion.js new file mode 100644 index 000000000..95519042b --- /dev/null +++ b/app/javascript/themes/glitch/util/reduced_motion.js @@ -0,0 +1,44 @@ +// Like react-motion's Motion, but reduces all animations to cross-fades +// for the benefit of users with motion sickness. +import React from 'react'; +import Motion from 'react-motion/lib/Motion'; +import PropTypes from 'prop-types'; + +const stylesToKeep = ['opacity', 'backgroundOpacity']; + +const extractValue = (value) => { + // This is either an object with a "val" property or it's a number + return (typeof value === 'object' && value && 'val' in value) ? value.val : value; +}; + +class ReducedMotion extends React.Component { + + static propTypes = { + defaultStyle: PropTypes.object, + style: PropTypes.object, + children: PropTypes.func, + } + + render() { + + const { style, defaultStyle, children } = this.props; + + Object.keys(style).forEach(key => { + if (stylesToKeep.includes(key)) { + return; + } + // If it's setting an x or height or scale or some other value, we need + // to preserve the end-state value without actually animating it + style[key] = defaultStyle[key] = extractValue(style[key]); + }); + + return ( + <Motion style={style} defaultStyle={defaultStyle}> + {children} + </Motion> + ); + } + +} + +export default ReducedMotion; diff --git a/app/javascript/themes/glitch/util/rtl.js b/app/javascript/themes/glitch/util/rtl.js new file mode 100644 index 000000000..00870a15d --- /dev/null +++ b/app/javascript/themes/glitch/util/rtl.js @@ -0,0 +1,31 @@ +// U+0590 to U+05FF - Hebrew +// U+0600 to U+06FF - Arabic +// U+0700 to U+074F - Syriac +// U+0750 to U+077F - Arabic Supplement +// U+0780 to U+07BF - Thaana +// U+07C0 to U+07FF - N'Ko +// U+0800 to U+083F - Samaritan +// U+08A0 to U+08FF - Arabic Extended-A +// U+FB1D to U+FB4F - Hebrew presentation forms +// U+FB50 to U+FDFF - Arabic presentation forms A +// U+FE70 to U+FEFF - Arabic presentation forms B + +const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; + +export function isRtl(text) { + if (text.length === 0) { + return false; + } + + text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, ''); + text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, ''); + text = text.replace(/\s+/g, ''); + + const matches = text.match(rtlChars); + + if (!matches) { + return false; + } + + return matches.length / text.length > 0.3; +}; diff --git a/app/javascript/themes/glitch/util/schedule_idle_task.js b/app/javascript/themes/glitch/util/schedule_idle_task.js new file mode 100644 index 000000000..b04d4a8ee --- /dev/null +++ b/app/javascript/themes/glitch/util/schedule_idle_task.js @@ -0,0 +1,29 @@ +// Wrapper to call requestIdleCallback() to schedule low-priority work. +// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API +// for a good breakdown of the concepts behind this. + +import Queue from 'tiny-queue'; + +const taskQueue = new Queue(); +let runningRequestIdleCallback = false; + +function runTasks(deadline) { + while (taskQueue.length && deadline.timeRemaining() > 0) { + taskQueue.shift()(); + } + if (taskQueue.length) { + requestIdleCallback(runTasks); + } else { + runningRequestIdleCallback = false; + } +} + +function scheduleIdleTask(task) { + taskQueue.push(task); + if (!runningRequestIdleCallback) { + runningRequestIdleCallback = true; + requestIdleCallback(runTasks); + } +} + +export default scheduleIdleTask; diff --git a/app/javascript/themes/glitch/util/scroll.js b/app/javascript/themes/glitch/util/scroll.js new file mode 100644 index 000000000..2af07e0fb --- /dev/null +++ b/app/javascript/themes/glitch/util/scroll.js @@ -0,0 +1,30 @@ +const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b; + +const scroll = (node, key, target) => { + const startTime = Date.now(); + const offset = node[key]; + const gap = target - offset; + const duration = 1000; + let interrupt = false; + + const step = () => { + const elapsed = Date.now() - startTime; + const percentage = elapsed / duration; + + if (percentage > 1 || interrupt) { + return; + } + + node[key] = easingOutQuint(0, elapsed, offset, gap, duration); + requestAnimationFrame(step); + }; + + step(); + + return () => { + interrupt = true; + }; +}; + +export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position); +export const scrollTop = (node) => scroll(node, 'scrollTop', 0); diff --git a/app/javascript/themes/glitch/util/stream.js b/app/javascript/themes/glitch/util/stream.js new file mode 100644 index 000000000..36c68ffc5 --- /dev/null +++ b/app/javascript/themes/glitch/util/stream.js @@ -0,0 +1,73 @@ +import WebSocketClient from 'websocket.js'; + +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); + let polling = null; + + const setupPolling = () => { + polling = setInterval(() => { + pollingRefresh(dispatch); + }, 20000); + }; + + const clearPolling = () => { + if (polling) { + clearInterval(polling); + polling = null; + } + }; + + const subscription = getStream(streamingAPIBaseURL, accessToken, path, { + connected () { + if (pollingRefresh) { + clearPolling(); + } + onConnect(); + }, + + disconnected () { + if (pollingRefresh) { + setupPolling(); + } + onDisconnect(); + }, + + received (data) { + onReceive(data); + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + onConnect(); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + clearPolling(); + }; + + return disconnect; + }; +} + + +export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`); + + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + ws.onclose = disconnected; + ws.onreconnect = reconnected; + + return ws; +}; diff --git a/app/javascript/themes/glitch/util/url_regex.js b/app/javascript/themes/glitch/util/url_regex.js new file mode 100644 index 000000000..e676d1879 --- /dev/null +++ b/app/javascript/themes/glitch/util/url_regex.js @@ -0,0 +1,196 @@ +const regexen = {}; + +const regexSupplant = function(regex, flags) { + flags = flags || ''; + if (typeof regex !== 'string') { + if (regex.global && flags.indexOf('g') < 0) { + flags += 'g'; + } + if (regex.ignoreCase && flags.indexOf('i') < 0) { + flags += 'i'; + } + if (regex.multiline && flags.indexOf('m') < 0) { + flags += 'm'; + } + + regex = regex.source; + } + return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) { + var newRegex = regexen[name] || ''; + if (typeof newRegex !== 'string') { + newRegex = newRegex.source; + } + return newRegex; + }), flags); +}; + +const stringSupplant = function(str, values) { + return str.replace(/#\{(\w+)\}/g, function(match, name) { + return values[name] || ''; + }); +}; + +export const urlRegex = (function() { + regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/; + regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/; + regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/; + regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/); + regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen); + regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/); + regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/); + regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/); + regexen.validGTLD = regexSupplant(RegExp( + '(?:(?:' + + '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' + + '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' + + 'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' + + 'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' + + 'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' + + 'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' + + 'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' + + 'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' + + 'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' + + 'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' + + 'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' + + 'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' + + 'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' + + 'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' + + 'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' + + 'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' + + 'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' + + 'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' + + 'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' + + 'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' + + 'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' + + 'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' + + 'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' + + 'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' + + 'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' + + 'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' + + 'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' + + 'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' + + 'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' + + 'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' + + 'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' + + 'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' + + 'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' + + 'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' + + 'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' + + 'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' + + 'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' + + 'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' + + 'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' + + 'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' + + 'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' + + 'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' + + 'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' + + 'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' + + 'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' + + 'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' + + 'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' + + 'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' + + 'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' + + 'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' + + 'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' + + 'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' + + 'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' + + 'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' + + 'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' + + 'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' + + 'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' + + 'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' + + 'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' + + 'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' + + 'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' + + 'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' + + 'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' + + 'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' + + 'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' + + 'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' + + 'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' + + 'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' + + 'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' + + 'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' + + 'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' + + 'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' + + 'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' + + 'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' + + 'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' + + 'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' + + 'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' + + 'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' + + 'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' + + 'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' + + 'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' + + 'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' + + 'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' + + 'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' + + 'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' + + 'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' + + 'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' + + 'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' + + ')(?=[^0-9a-zA-Z@]|$))')); + regexen.validCCTLD = regexSupplant(RegExp( + '(?:(?:' + + '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' + + 'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' + + 'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' + + 'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' + + 'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' + + 're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' + + 'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' + + 'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' + + 'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' + + 'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' + + 'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' + + ')(?=[^0-9a-zA-Z@]|$))')); + regexen.validPunycode = /(?:xn--[0-9a-z]+)/; + regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/; + regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/); + regexen.validPortNumber = /[0-9]+/; + regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/; + regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i); + // Allow URL paths to contain up to two nested levels of balanced parens + // 1. Used in Wikipedia URLs like /Primer_(film) + // 2. Used in IIS sessions like /S(dfd346)/ + // 3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/ + regexen.validUrlBalancedParens = regexSupplant( + '\\(' + + '(?:' + + '#{validGeneralUrlPathChars}+' + + '|' + + // allow one nested level of balanced parentheses + '(?:' + + '#{validGeneralUrlPathChars}*' + + '\\(' + + '#{validGeneralUrlPathChars}+' + + '\\)' + + '#{validGeneralUrlPathChars}*' + + ')' + + ')' + + '\\)' + , 'i'); + // Valid end-of-path chracters (so /foo. does not gobble the period). + // 1. Allow =&# for empty URL parameters and other URL-join artifacts + regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i); + // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/ + regexen.validUrlPath = regexSupplant('(?:' + + '(?:' + + '#{validGeneralUrlPathChars}*' + + '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' + + '#{validUrlPathEndingChars}'+ + ')|(?:@#{validGeneralUrlPathChars}+\/)'+ + ')', 'i'); + regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i; + regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i; + regexen.validUrl = regexSupplant( + '(' + // $1 URL + '(https?:\\/\\/)' + // $2 Protocol + '(#{validDomain})' + // $3 Domain(s) + '(?::(#{validPortNumber}))?' + // $4 Port number (optional) + '(\\/#{validUrlPath}*)?' + // $5 URL Path + '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $6 Query String + ')' + , 'gi'); + return regexen.validUrl; +}()); diff --git a/app/javascript/themes/glitch/util/uuid.js b/app/javascript/themes/glitch/util/uuid.js new file mode 100644 index 000000000..be1899305 --- /dev/null +++ b/app/javascript/themes/glitch/util/uuid.js @@ -0,0 +1,3 @@ +export default function uuid(a) { + return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid); +}; diff --git a/app/javascript/themes/glitch/util/web_push_subscription.js b/app/javascript/themes/glitch/util/web_push_subscription.js new file mode 100644 index 000000000..70b26105b --- /dev/null +++ b/app/javascript/themes/glitch/util/web_push_subscription.js @@ -0,0 +1,105 @@ +import axios from 'axios'; +import { store } from 'themes/glitch/containers/mastodon'; +import { setBrowserSupport, setSubscription, clearSubscription } from 'themes/glitch/actions/push_notifications'; + +// Taken from https://www.npmjs.com/package/web-push +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; + +const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); + +const getRegistration = () => navigator.serviceWorker.ready; + +const getPushSubscription = (registration) => + registration.pushManager.getSubscription() + .then(subscription => ({ registration, subscription })); + +const subscribe = (registration) => + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()), + }); + +const unsubscribe = ({ registration, subscription }) => + subscription ? subscription.unsubscribe().then(() => registration) : registration; + +const sendSubscriptionToBackend = (subscription) => + axios.post('/api/web/push_subscriptions', { + subscription, + }).then(response => response.data); + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +export function register () { + store.dispatch(setBrowserSupport(supportsPushNotifications)); + + if (supportsPushNotifications) { + if (!getApplicationServerKey()) { + console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); + return; + } + + getRegistration() + .then(getPushSubscription) + .then(({ registration, subscription }) => { + if (subscription !== null) { + // We have a subscription, check if it is still valid + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString(); + const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid + if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { + return subscription; + } else { + // Something went wrong, try to subscribe again + return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend); + } + } + + // No subscription, try to subscribe + return subscribe(registration).then(sendSubscriptionToBackend); + }) + .then(subscription => { + // If we got a PushSubscription (and not a subscription object from the backend) + // it means that the backend subscription is valid (and was set during hydration) + if (!(subscription instanceof PushSubscription)) { + store.dispatch(setSubscription(subscription)); + } + }) + .catch(error => { + if (error.code === 20 && error.name === 'AbortError') { + console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); + } else if (error.code === 5 && error.name === 'InvalidCharacterError') { + console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); + } + + // Clear alerts and hide UI settings + store.dispatch(clearSubscription()); + + try { + getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + } catch (e) { + + } + }); + } else { + console.warn('Your browser does not support Web Push Notifications.'); + } +} diff --git a/app/javascript/themes/mastodon-go b/app/javascript/themes/mastodon-go new file mode 160000 +Subproject 74c0293e83dbb49ea4f27eea108526df6216d2a diff --git a/app/javascript/themes/vanilla/theme.yml b/app/javascript/themes/vanilla/theme.yml new file mode 100644 index 000000000..b4a1598fc --- /dev/null +++ b/app/javascript/themes/vanilla/theme.yml @@ -0,0 +1,26 @@ +# (REQUIRED) The location of the pack files inside `pack_directory`. +pack: + about: about.js + admin: null + common: common.js + embed: null + home: application.js + public: public.js + settings: null + share: share.js + +# (OPTIONAL) The directory which contains the pack files. +# Defaults to the theme directory (`app/javascript/themes/[theme]`), +# but in the case of the vanilla Mastodon theme the pack files are +# somewhere else. +pack_directory: app/javascript/packs + +# (OPTIONAL) Additional javascript resources to preload, for use with +# lazy-loaded components. It is **STRONGLY RECOMMENDED** that you +# derive these pathnames from `themes/[your-theme]` to ensure that +# they stay unique. (Of course, vanilla doesn't do this ^^;;) +preload: +- features/getting_started +- features/compose +- features/home_timeline +- features/notifications diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index d6e9bc1de..66e4f7c5e 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -53,9 +53,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_tags(status) - return unless @object['tag'].is_a?(Array) + return if @object['tag'].nil? - @object['tag'].each do |tag| + as_array(@object['tag']).each do |tag| case tag['type'] when 'Hashtag' process_hashtag tag, status @@ -103,9 +103,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_attachments(status) - return unless @object['attachment'].is_a?(Array) + return if @object['attachment'].nil? - @object['attachment'].each do |attachment| + as_array(@object['attachment']).each do |attachment| next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? href = Addressable::URI.parse(attachment['url']).normalize.to_s @@ -173,7 +173,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def language_from_content - return nil unless language_map? + return LanguageDetector.instance.detect(text_from_content, @account) unless language_map? @object['contentMap'].keys.first end diff --git a/app/lib/extractor.rb b/app/lib/extractor.rb index 957364293..738ec89a0 100644 --- a/app/lib/extractor.rb +++ b/app/lib/extractor.rb @@ -5,7 +5,8 @@ module Extractor module_function - def extract_mentions_or_lists_with_indices(text) # :yields: username, list_slug, start, end + # :yields: username, list_slug, start, end + def extract_mentions_or_lists_with_indices(text) return [] unless text =~ Twitter::Regex[:at_signs] possible_entries = [] diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 58650efb6..76365c7d3 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -26,34 +26,42 @@ class FeedManager end end - def push(timeline_type, account, status) - return false unless add_to_feed(timeline_type, account, status) - - trim(timeline_type, account.id) - - PushUpdateWorker.perform_async(account.id, status.id) if push_update_required?(timeline_type, account.id) - + def push_to_home(account, status) + return false unless add_to_feed(:home, account.id, status) + trim(:home, account.id) + PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}") true end - def unpush(timeline_type, account, status) - return false unless remove_from_feed(timeline_type, account, status) + def unpush_from_home(account, status) + return false unless remove_from_feed(:home, account.id, status) + Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) + true + end - payload = Oj.dump(event: :delete, payload: status.id.to_s) - Redis.current.publish("timeline:#{account.id}", payload) + def push_to_list(list, status) + return false unless add_to_feed(:list, list.id, status) + trim(:list, list.id) + PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}") + true + end + def unpush_from_list(list, status) + return false unless remove_from_feed(:list, list.id, status) + Redis.current.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) true end def trim(type, account_id) timeline_key = key(type, account_id) - reblog_key = key(type, account_id, 'reblogs') + 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_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 @@ -69,10 +77,6 @@ class FeedManager end end - def push_update_required?(timeline_type, account_id) - timeline_type != :home || redis.get("subscribed:timeline:#{account_id}").present? - end - def merge_into_timeline(from_account, into_account) timeline_key = key(:home, into_account.id) query = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4) @@ -84,28 +88,28 @@ class FeedManager query.each do |status| next if status.direct_visibility? || filter?(:home, status, into_account) - add_to_feed(:home, into_account, status) + add_to_feed(:home, into_account.id, status) end trim(:home, into_account.id) end def unmerge_from_timeline(from_account, into_account) - timeline_key = key(:home, into_account.id) + 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, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status| - remove_from_feed(:home, into_account, status) + remove_from_feed(:home, into_account.id, status) end end def clear_from_timeline(account, target_account) - timeline_key = key(:home, account.id) + timeline_key = key(:home, account.id) timeline_status_ids = redis.zrange(timeline_key, 0, -1) - target_statuses = Status.where(id: timeline_status_ids, account: target_account) + target_statuses = Status.where(id: timeline_status_ids, account: target_account) target_statuses.each do |status| - unpush(:home, account, status) + unpush_from_home(account, status) end end @@ -122,7 +126,7 @@ class FeedManager statuses.each do |status| next if filter_from_home?(status, account) - added += 1 if add_to_feed(:home, account, status) + added += 1 if add_to_feed(:home, account.id, status) end break unless added.zero? @@ -137,11 +141,18 @@ class FeedManager Redis.current end + def push_update_required?(timeline_id) + redis.exists("subscribed:#{timeline_id}") + end + def filter_from_home?(status, receiver_id) return false if receiver_id == status.account_id return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) + return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) + check_for_mutes = [status.account_id] + check_for_mutes.concat(status.mentions.pluck(:account_id)) check_for_mutes.concat([status.reblog.account_id]) if status.reblog? return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any? @@ -157,7 +168,9 @@ class FeedManager should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply return should_filter elsif status.reblog? # Filter out a reblog - should_filter = Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me + src_id = status.account_id + should_filter = Follow.where(account_id: receiver_id, target_account_id: src_id, show_reblogs: false).exists? # if the reblogger's reblogs are suppressed + should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists? # or the author's domain is blocked return should_filter end @@ -165,6 +178,18 @@ class FeedManager false end + def keyword_filter?(status, matcher) + should_filter = matcher =~ status.text + should_filter ||= matcher =~ status.spoiler_text + + if status.reblog? + should_filter ||= matcher =~ status.reblog.text + should_filter ||= matcher =~ status.reblog.spoiler_text + end + + !!should_filter + end + def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id @@ -174,6 +199,7 @@ class FeedManager should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them + should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) # or if the mention contains a muted keyword should_filter end @@ -182,9 +208,9 @@ class FeedManager # 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') + def add_to_feed(timeline_type, account_id, 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 @@ -195,6 +221,7 @@ class FeedManager return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id) + if reblog_rank.nil? # This is not something we've already seen reblogged, so we # can just add it to the feed (and note that we're @@ -205,7 +232,7 @@ class FeedManager # Another reblog of the same status was already in the # REBLOG_FALLOFF most recent statuses, so we note that this # is an "extra" reblog, by storing it in reblog_set_key. - reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}") + reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}") redis.sadd(reblog_set_key, status.id) return false end @@ -220,8 +247,8 @@ class FeedManager # 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) + def remove_from_feed(timeline_type, account_id, status) + timeline_key = key(timeline_type, account_id) if status.reblog? # 1. If the reblogging status is not in the feed, stop. @@ -229,7 +256,7 @@ class FeedManager return false if status_rank.nil? # 2. Remove reblog from set of this status's reblogs. - reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}") + reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}") redis.srem(reblog_set_key, status.id) # 3. Re-insert another reblog or original into the feed if one @@ -244,7 +271,7 @@ class FeedManager # (outside conditional) else # If the original is getting deleted, no use for reblog references - redis.del(key(timeline_type, account.id, "reblogs:#{status.id}")) + redis.del(key(timeline_type, account_id, "reblogs:#{status.id}")) end redis.zrem(timeline_key, status.id) diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 57f105da7..733a1c4b7 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -89,20 +89,28 @@ class Formatter end end + def count_tag_nesting(tag) + if tag[1] == '/' then -1 + elsif tag[-2] == '/' then 0 + else 1 + end + end + def encode_custom_emojis(html, emojis) return html if emojis.empty? emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url(:static))] }.to_h i = -1 - inside_tag = false + tag_open_index = nil inside_shortname = false shortname_start_index = -1 + invisible_depth = 0 while i + 1 < html.size i += 1 - if inside_shortname && html[i] == ':' + if invisible_depth.zero? && inside_shortname && html[i] == ':' shortcode = html[shortname_start_index + 1..i - 1] emoji = emoji_map[shortcode] @@ -116,12 +124,18 @@ class Formatter end inside_shortname = false - elsif inside_tag && html[i] == '>' - inside_tag = false + elsif tag_open_index && html[i] == '>' + tag = html[tag_open_index..i] + tag_open_index = nil + if invisible_depth.positive? + invisible_depth += count_tag_nesting(tag) + elsif tag == '<span class="invisible">' + invisible_depth = 1 + end elsif html[i] == '<' - inside_tag = true + tag_open_index = i inside_shortname = false - elsif !inside_tag && html[i] == ':' + elsif !tag_open_index && html[i] == ':' inside_shortname = true shortname_start_index = i end diff --git a/app/lib/frontmatter_handler.rb b/app/lib/frontmatter_handler.rb new file mode 100644 index 000000000..83e5f465e --- /dev/null +++ b/app/lib/frontmatter_handler.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +require 'singleton' + +# See also `app/javascript/features/account/util/bio_metadata.js`. + +class FrontmatterHandler + include Singleton + + # CONVENIENCE FUNCTIONS # + + def self.unirex(str) + Regexp.new str, Regexp::MULTILINE, 'u' + end + def self.rexstr(exp) + '(?:' + exp.source + ')' + end + + # CHARACTER CLASSES # + + DOCUMENT_START = /^/ + DOCUMENT_END = /$/ + ALLOWED_CHAR = # c-printable` in the YAML 1.2 spec. + /[\t\n\r\u{20}-\u{7e}\u{85}\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u + WHITE_SPACE = /[ \t]/ + INDENTATION = / */ + LINE_BREAK = /\r?\n|\r|<br\s*\/?>/ + ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/ + HEXADECIMAL_CHARS = /[0-9a-fA-F]/ + INDICATOR = /[-?:,\[\]{}&#*!|>'"%@`]/ + FLOW_CHAR = /[,\[\]{}]/ + + # NEGATED CHARACTER CLASSES # + + NOT_WHITE_SPACE = unirex '(?!' + rexstr(WHITE_SPACE) + ').' + NOT_LINE_BREAK = unirex '(?!' + rexstr(LINE_BREAK) + ').' + NOT_INDICATOR = unirex '(?!' + rexstr(INDICATOR) + ').' + NOT_FLOW_CHAR = unirex '(?!' + rexstr(FLOW_CHAR) + ').' + NOT_ALLOWED_CHAR = unirex '(?!' + rexstr(ALLOWED_CHAR) + ').' + + # BASIC CONSTRUCTS # + + ANY_WHITE_SPACE = unirex rexstr(WHITE_SPACE) + '*' + ANY_ALLOWED_CHARS = unirex rexstr(ALLOWED_CHAR) + '*' + NEW_LINE = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ) + SOME_NEW_LINES = unirex( + '(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+' + ) + POSSIBLE_STARTS = unirex( + rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?' + ) + POSSIBLE_ENDS = unirex( + rexstr(SOME_NEW_LINES) + '|' + + rexstr(DOCUMENT_END) + '|' + + rexstr(/<\/p>/) + ) + CHARACTER_ESCAPE = unirex( + rexstr(/\\/) + + '(?:' + + rexstr(ESCAPE_CHAR) + '|' + + rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' + + rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' + + rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' + + ')' + ) + ESCAPED_CHAR = unirex( + rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' + + rexstr(CHARACTER_ESCAPE) + ) + ANY_ESCAPED_CHARS = unirex( + rexstr(ESCAPED_CHAR) + '*' + ) + ESCAPED_APOS = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/) + ) + ANY_ESCAPED_APOS = unirex( + rexstr(ESCAPED_APOS) + '*' + ) + FIRST_KEY_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + ) + FIRST_VALUE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + # Flow indicators are allowed in values. + ) + LATER_KEY_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + ) + LATER_VALUE_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + # Flow indicators are allowed in values. + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + ) + + # YAML CONSTRUCTS # + + YAML_START = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/---/) + ) + YAML_END = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/) + ) + YAML_LOOKAHEAD = unirex( + '(?=' + + rexstr(YAML_START) + + rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) + + ')' + ) + YAML_DOUBLE_QUOTE = unirex( + rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/) + ) + YAML_SINGLE_QUOTE = unirex( + rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/) + ) + YAML_SIMPLE_KEY = unirex( + rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*' + ) + YAML_SIMPLE_VALUE = unirex( + rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*' + ) + YAML_KEY = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_KEY) + ) + YAML_VALUE = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_VALUE) + ) + YAML_SEPARATOR = unirex( + rexstr(ANY_WHITE_SPACE) + + ':' + rexstr(WHITE_SPACE) + + rexstr(ANY_WHITE_SPACE) + ) + YAML_LINE = unirex( + '(' + rexstr(YAML_KEY) + ')' + + rexstr(YAML_SEPARATOR) + + '(' + rexstr(YAML_VALUE) + ')' + ) + + # FRONTMATTER REGEX # + + YAML_FRONTMATTER = unirex( + rexstr(POSSIBLE_STARTS) + + rexstr(YAML_LOOKAHEAD) + + rexstr(YAML_START) + rexstr(SOME_NEW_LINES) + + '(?:' + + '(' + rexstr(INDENTATION) + ')' + + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '(?:' + + '\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '){0,4}' + + ')?' + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) + ) + + # SEARCHES # + + FIND_YAML_LINES = unirex( + rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE) + ) + + # STRING PROCESSING # + + def process_string(str) + case str[0] + when '"' + str[1..-2] + .gsub(/\\0/, "\u{00}") + .gsub(/\\a/, "\u{07}") + .gsub(/\\b/, "\u{08}") + .gsub(/\\t/, "\u{09}") + .gsub(/\\\u{09}/, "\u{09}") + .gsub(/\\n/, "\u{0a}") + .gsub(/\\v/, "\u{0b}") + .gsub(/\\f/, "\u{0c}") + .gsub(/\\r/, "\u{0d}") + .gsub(/\\e/, "\u{1b}") + .gsub(/\\ /, "\u{20}") + .gsub(/\\"/, "\u{22}") + .gsub(/\\\//, "\u{2f}") + .gsub(/\\\\/, "\u{5c}") + .gsub(/\\N/, "\u{85}") + .gsub(/\\_/, "\u{a0}") + .gsub(/\\L/, "\u{2028}") + .gsub(/\\P/, "\u{2029}") + .gsub(/\\x([0-9a-fA-F]{2})/mu) {|s| $1.to_i.chr Encoding::UTF_8} + .gsub(/\\u([0-9a-fA-F]{4})/mu) {|s| $1.to_i.chr Encoding::UTF_8} + .gsub(/\\U([0-9a-fA-F]{8})/mu) {|s| $1.to_i.chr Encoding::UTF_8} + when "'" + str[1..-2].gsub(/''/, "'") + else + str + end + end + + # BIO PROCESSING # + + def process_bio content + result = { + text: content.gsub(/"/, '"').gsub(/'/, "'"), + metadata: [] + } + yaml = YAML_FRONTMATTER.match(result[:text]) + return result unless yaml + yaml = yaml[0] + start = YAML_START =~ result[:text] + ending = start + yaml.length - (YAML_START =~ yaml) + result[:text][start..ending - 1] = '' + metadata = nil + index = 0 + while metadata = FIND_YAML_LINES.match(yaml, index) do + index = metadata.end(0) + result[:metadata].push [ + process_string(metadata[1]), process_string(metadata[2]) + ] + end + return result + end + +end diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb index a42460e10..c6f52f0c7 100644 --- a/app/lib/language_detector.rb +++ b/app/lib/language_detector.rb @@ -38,12 +38,31 @@ class LanguageDetector end def simplify_text(text) - text.dup.tap do |new_text| - new_text.gsub!(FetchLinkCardService::URL_PATTERN, '') - new_text.gsub!(Account::MENTION_RE, '') - new_text.gsub!(Tag::HASHTAG_RE, '') - new_text.gsub!(/\s+/, ' ') - end + new_text = remove_html(text) + new_text.gsub!(FetchLinkCardService::URL_PATTERN, '') + new_text.gsub!(Account::MENTION_RE, '') + new_text.gsub!(Tag::HASHTAG_RE, '') + new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '') + new_text.gsub!(/\s+/, ' ') + new_text + end + + def new_scrubber + scrubber = Rails::Html::PermitScrubber.new + scrubber.tags = %w(br p) + scrubber + end + + def scrubber + @scrubber ||= new_scrubber + end + + def remove_html(text) + text = Loofah.fragment(text).scrub!(scrubber).to_s + text.gsub!('<br>', "\n") + text.gsub!('</p><p>', "\n\n") + text.gsub!(/(^<p>|<\/p>$)/, '') + text end def default_locale(account) diff --git a/app/lib/themes.rb b/app/lib/themes.rb index 243ffb9ab..f7ec22fd2 100644 --- a/app/lib/themes.rb +++ b/app/lib/themes.rb @@ -7,7 +7,19 @@ class Themes include Singleton def initialize - @conf = YAML.load_file(Rails.root.join('config', 'themes.yml')) + result = Hash.new + Dir.glob(Rails.root.join('app', 'javascript', 'themes', '*', 'theme.yml')) do |path| + data = YAML.load_file(path) + name = File.basename(File.dirname(path)) + if data['pack'] + result[name] = data + end + end + @conf = result + end + + def get(name) + @conf[name] end def names diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index d48e1da65..d86959c0b 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -76,7 +76,7 @@ class UserSettingsDecorator def theme_preference settings['setting_theme'] end - + def boolean_cast_setting(key) settings[key] == '1' end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 80c9d8ccf..d79f26366 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -7,6 +7,8 @@ class NotificationMailer < ApplicationMailer @me = recipient @status = notification.target_status + return if @me.user.disabled? + locale_for_account(@me) do thread_by_conversation(@status.conversation) mail to: @me.user.email, subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct) @@ -17,6 +19,8 @@ class NotificationMailer < ApplicationMailer @me = recipient @account = notification.from_account + return if @me.user.disabled? + locale_for_account(@me) do mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) end @@ -27,6 +31,8 @@ class NotificationMailer < ApplicationMailer @account = notification.from_account @status = notification.target_status + return if @me.user.disabled? + locale_for_account(@me) do thread_by_conversation(@status.conversation) mail to: @me.user.email, subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct) @@ -38,6 +44,8 @@ class NotificationMailer < ApplicationMailer @account = notification.from_account @status = notification.target_status + return if @me.user.disabled? + locale_for_account(@me) do thread_by_conversation(@status.conversation) mail to: @me.user.email, subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct) @@ -48,6 +56,8 @@ class NotificationMailer < ApplicationMailer @me = recipient @account = notification.from_account + return if @me.user.disabled? + locale_for_account(@me) do mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) end @@ -59,15 +69,11 @@ class NotificationMailer < ApplicationMailer @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since) @follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count - return if @notifications.empty? + return if @me.user.disabled? || @notifications.empty? locale_for_account(@me) do mail to: @me.user.email, - subject: I18n.t( - :subject, - scope: [:notification_mailer, :digest], - count: @notifications.size - ) + subject: I18n.t(:subject, scope: [:notification_mailer, :digest], count: @notifications.size) end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index c475a9911..bdb29ebad 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -10,6 +10,8 @@ class UserMailer < Devise::Mailer @token = token @instance = Rails.configuration.x.local_domain + return if @resource.disabled? + I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.unconfirmed_email.blank? ? @resource.email : @resource.unconfirmed_email, subject: I18n.t('devise.mailer.confirmation_instructions.subject', instance: @instance) end @@ -20,6 +22,8 @@ class UserMailer < Devise::Mailer @token = token @instance = Rails.configuration.x.local_domain + return if @resource.disabled? + I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject') end @@ -29,6 +33,8 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain + return if @resource.disabled? + I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject') end diff --git a/app/models/account.rb b/app/models/account.rb index 3dc2a95ab..a4b8e1c0b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -41,10 +41,11 @@ # shared_inbox_url :string default(""), not null # followers_url :string default(""), not null # protocol :integer default("ostatus"), not null +# memorial :boolean default(FALSE), not null # class Account < ApplicationRecord - MENTION_RE = /(?:^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i + MENTION_RE = /(?<=^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i include AccountAvatar include AccountFinderConcern @@ -52,6 +53,9 @@ class Account < ApplicationRecord include AccountInteractions include Attachmentable include Remotable + include Paginable + + MAX_NOTE_LENGTH = 500 enum protocol: [:ostatus, :activitypub] @@ -67,7 +71,7 @@ class Account < ApplicationRecord validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? } validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? } validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? } - validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? } + validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? } # Timelines has_many :stream_entries, inverse_of: :account, dependent: :destroy @@ -94,6 +98,10 @@ class Account < ApplicationRecord has_many :account_moderation_notes, dependent: :destroy has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy + # Lists + has_many :list_accounts, inverse_of: :account, dependent: :destroy + has_many :lists, through: :list_accounts + scope :remote, -> { where.not(domain: nil) } scope :local, -> { where(domain: nil) } scope :without_followers, -> { where(followers_count: 0) } @@ -114,6 +122,8 @@ class Account < ApplicationRecord :current_sign_in_at, :confirmed?, :admin?, + :moderator?, + :staff?, :locale, to: :user, prefix: true, @@ -150,6 +160,20 @@ class Account < ApplicationRecord ResolveRemoteAccountService.new.call(acct) end + def unsuspend! + transaction do + user&.enable! if local? + update!(suspended: false) + end + end + + def memorialize! + transaction do + user&.disable! if local? + update!(memorial: true) + end + end + def keypair @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end @@ -292,6 +316,22 @@ class Account < ApplicationRecord self.public_key = keypair.public_key.to_pem end + YAML_START = "---\r\n" + YAML_END = "\r\n...\r\n" + + def note_length_does_not_exceed_length_limit + note_without_metadata = note + if note.start_with? YAML_START + idx = note.index YAML_END + unless idx.nil? + note_without_metadata = note[(idx + YAML_END.length) .. -1] + end + end + if note_without_metadata.mb_chars.grapheme_length > MAX_NOTE_LENGTH + errors.add(:note, "can't be longer than 500 graphemes") + end + end + def normalize_domain return if local? diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb index fb695e473..35810b6c2 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 # 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/block.rb b/app/models/block.rb index a913782ed..284abfe4c 100644 --- a/app/models/block.rb +++ b/app/models/block.rb @@ -3,10 +3,10 @@ # # Table name: blocks # +# id :integer not null, primary key # 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 # diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb index 561c7ab9f..2e8a7fb37 100644 --- a/app/models/concerns/account_finder_concern.rb +++ b/app/models/concerns/account_finder_concern.rb @@ -44,7 +44,7 @@ module AccountFinderConcern end def with_usernames - Account.where.not(username: [nil, '']) + Account.where.not(username: '') end def matching_username diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index b26520f5b..c41f92581 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -5,7 +5,11 @@ module AccountInteractions class_methods do def following_map(target_account_ids, account_id) - follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping| + mapping[follow.target_account_id] = { + reblogs: follow.show_reblogs? + } + end end def followed_by_map(target_account_ids, account_id) @@ -17,11 +21,19 @@ module AccountInteractions end def muting_map(target_account_ids, account_id) - follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping| + mapping[mute.target_account_id] = { + notifications: mute.hide_notifications?, + } + end end def requested_map(target_account_ids, account_id) - follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping| + mapping[follow_request.target_account_id] = { + reblogs: follow_request.show_reblogs? + } + end end def domain_blocking_map(target_account_ids, account_id) @@ -62,16 +74,25 @@ module AccountInteractions has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy end - def follow!(other_account) - active_relationships.find_or_create_by!(target_account: other_account) + def follow!(other_account, reblogs: nil) + reblogs = true if reblogs.nil? + rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account) + rel.update!(show_reblogs: reblogs) + + rel end def block!(other_account) block_relationships.find_or_create_by!(target_account: other_account) end - def mute!(other_account) - mute_relationships.find_or_create_by!(target_account: other_account) + def mute!(other_account, notifications: nil) + notifications = true if notifications.nil? + mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) + # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. + if mute.hide_notifications? != notifications + mute.update!(hide_notifications: notifications) + end end def mute_conversation!(conversation) @@ -127,6 +148,14 @@ module AccountInteractions conversation_mutes.where(conversation: conversation).exists? end + def muting_notifications?(other_account) + mute_relationships.where(target_account: other_account, hide_notifications: true).exists? + end + + def muting_reblogs?(other_account) + active_relationships.where(target_account: other_account, show_reblogs: false).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb index 8d2399adf..248cdfe6e 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 # 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 65d9840d5..a77b53c98 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -15,6 +15,7 @@ # disabled :boolean default(FALSE), not null # uri :string # image_remote_url :string +# visible_in_picker :boolean default(TRUE), not null # class CustomEmoji < ApplicationRecord @@ -24,6 +25,8 @@ class CustomEmoji < ApplicationRecord :(#{SHORTCODE_RE_FRAGMENT}): (?=[^[:alnum:]:]|$)/x + has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode + 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 } diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 1268290bc..aea8919af 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 index 839038bea..a104810d1 100644 --- a/app/models/email_domain_block.rb +++ b/app/models/email_domain_block.rb @@ -4,14 +4,33 @@ # Table name: email_domain_blocks # # id :integer not null, primary key -# domain :string not null +# domain :string default(""), not null # created_at :datetime not null # updated_at :datetime not null # class EmailDomainBlock < ApplicationRecord + before_validation :normalize_domain + + validates :domain, presence: true, uniqueness: true + def self.block?(email) - domain = email.gsub(/.+@([^.]+)/, '\1') + _, domain = email.split('@', 2) + + return true if domain.nil? + + begin + domain = TagManager.instance.normalize_domain(domain) + rescue Addressable::URI::InvalidURIError + return true + end + where(domain: domain).exists? end + + private + + def normalize_domain + self.domain = TagManager.instance.normalize_domain(domain) + end end diff --git a/app/models/favourite.rb b/app/models/favourite.rb index d28d5c05b..c38838f2a 100644 --- a/app/models/favourite.rb +++ b/app/models/favourite.rb @@ -3,10 +3,10 @@ # # Table name: favourites # +# id :integer not null, primary key # 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 # diff --git a/app/models/feed.rb b/app/models/feed.rb index 5f7b7877a..d99f1ffb2 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -1,36 +1,27 @@ # frozen_string_literal: true class Feed - def initialize(type, account) - @type = type - @account = account + def initialize(type, id) + @type = type + @id = id end def get(limit, max_id = nil, since_id = nil) - if redis.exists("account:#{@account.id}:regeneration") - from_database(limit, max_id, since_id) - else - from_redis(limit, max_id, since_id) - end + from_redis(limit, max_id, since_id) end - private + protected 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(&:first).map(&:to_i) - Status.where(id: unhydrated).cache_ids - end - def from_database(limit, max_id, since_id) - Status.as_home_timeline(@account) - .paginate_by_max_id(limit, max_id, since_id) - .reject { |status| FeedManager.instance.filter?(:home, status, @account.id) } + Status.where(id: unhydrated).cache_ids end def key - FeedManager.instance.key(@type, @account.id) + FeedManager.instance.key(@type, @id) end def redis diff --git a/app/models/follow.rb b/app/models/follow.rb index 667720a88..3fb665afc 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -3,11 +3,12 @@ # # Table name: follows # +# id :integer not null, primary key # 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 +# show_reblogs :boolean default(TRUE), not null # class Follow < ApplicationRecord diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 60036d903..ebf6959ce 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -3,11 +3,12 @@ # # Table name: follow_requests # +# id :integer not null, primary key # 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 +# show_reblogs :boolean default(TRUE), not null # class FollowRequest < ApplicationRecord @@ -21,13 +22,11 @@ class FollowRequest < ApplicationRecord validates :account_id, uniqueness: { scope: :target_account_id } def authorize! - account.follow!(target_account) + account.follow!(target_account, reblogs: show_reblogs) MergeWorker.perform_async(target_account.id, account.id) destroy! end - def reject! - destroy! - end + alias reject! destroy! end diff --git a/app/models/glitch.rb b/app/models/glitch.rb new file mode 100644 index 000000000..0e497babc --- /dev/null +++ b/app/models/glitch.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Glitch + def self.table_name_prefix + 'glitch_' + end +end diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb new file mode 100644 index 000000000..009de1880 --- /dev/null +++ b/app/models/glitch/keyword_mute.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: glitch_keyword_mutes +# +# id :integer not null, primary key +# account_id :integer not null +# keyword :string not null +# whole_word :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Glitch::KeywordMute < ApplicationRecord + belongs_to :account, required: true + + validates_presence_of :keyword + + after_commit :invalidate_cached_matcher + + def self.matcher_for(account_id) + Matcher.new(account_id) + end + + private + + def invalidate_cached_matcher + Rails.cache.delete("keyword_mutes:regex:#{account_id}") + end + + class Matcher + attr_reader :account_id + attr_reader :regex + + def initialize(account_id) + @account_id = account_id + regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account } + @regex = /#{regex_text}/ + end + + def =~(str) + regex =~ str + end + + private + + def keywords + Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word) + end + + def regex_text_for_account + kws = keywords.find_each.with_object([]) do |kw, a| + a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword) + end + + Regexp.union(kws).source + end + + def boundary_regex_for_keyword(keyword) + sb = keyword =~ /\A[[:word:]]/ ? '\b' : '' + eb = keyword =~ /[[:word:]]\Z/ ? '\b' : '' + + /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/ + end + end +end diff --git a/app/models/home_feed.rb b/app/models/home_feed.rb new file mode 100644 index 000000000..b943a34ce --- /dev/null +++ b/app/models/home_feed.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class HomeFeed < Feed + def initialize(account) + @type = :home + @id = account.id + @account = account + end + + def get(limit, max_id = nil, since_id = nil) + if redis.exists("account:#{@account.id}:regeneration") + from_database(limit, max_id, since_id) + else + super + end + end + + private + + def from_database(limit, max_id, since_id) + Status.as_home_timeline(@account) + .paginate_by_max_id(limit, max_id, since_id) + .reject { |status| FeedManager.instance.filter?(:home, status, @account.id) } + end +end diff --git a/app/models/import.rb b/app/models/import.rb index 8ae7e3a46..091fb3044 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -3,6 +3,7 @@ # # Table name: imports # +# id :integer not null, primary key # type :integer not null # approved :boolean default(FALSE), not null # created_at :datetime not null @@ -12,7 +13,6 @@ # 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/list.rb b/app/models/list.rb new file mode 100644 index 000000000..5d7ba0065 --- /dev/null +++ b/app/models/list.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: lists +# +# id :integer not null, primary key +# account_id :integer +# title :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class List < ApplicationRecord + include Paginable + + belongs_to :account + + has_many :list_accounts, inverse_of: :list, dependent: :destroy + has_many :accounts, through: :list_accounts + + validates :title, presence: true +end diff --git a/app/models/list_account.rb b/app/models/list_account.rb new file mode 100644 index 000000000..c08239aa0 --- /dev/null +++ b/app/models/list_account.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: list_accounts +# +# id :integer not null, primary key +# list_id :integer not null +# account_id :integer not null +# follow_id :integer not null +# + +class ListAccount < ApplicationRecord + belongs_to :list, required: true + belongs_to :account, required: true + belongs_to :follow, required: true + + before_validation :set_follow + + private + + def set_follow + self.follow = Follow.find_by(account_id: list.account_id, target_account_id: account.id) + end +end diff --git a/app/models/list_feed.rb b/app/models/list_feed.rb new file mode 100644 index 000000000..f371e4ed9 --- /dev/null +++ b/app/models/list_feed.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ListFeed < Feed + def initialize(list) + @type = :list + @id = list.id + end +end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 60380198b..368ccef3a 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -10,12 +10,12 @@ # file_file_size :integer # file_updated_at :datetime # remote_url :string default(""), not null -# account_id :integer # created_at :datetime not null # updated_at :datetime not null # shortcode :string # type :integer default("image"), not null # file_meta :json +# account_id :integer # description :text # @@ -24,15 +24,32 @@ require 'mime/types' class MediaAttachment < ApplicationRecord self.inheritance_column = nil - enum type: [:image, :gifv, :video, :unknown] + enum type: [:image, :gifv, :video, :audio, :unknown] IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v'].freeze + AUDIO_FILE_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.ogg'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze + AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze + AUDIO_STYLES = { + original: { + format: 'mp4', + convert_options: { + output: { + filter_complex: '"[0:a]compand,showwaves=s=640x360:mode=line,format=yuv420p[v]"', + map: '"[v]" -map 0:a', + threads: 2, + vcodec: 'libx264', + acodec: 'aac', + movflags: '+faststart', + }, + }, + }, + }.freeze VIDEO_STYLES = { small: { convert_options: { @@ -55,7 +72,7 @@ class MediaAttachment < ApplicationRecord include Remotable - validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES validates_attachment_size :file, less_than: 8.megabytes validates :account, presence: true @@ -110,6 +127,8 @@ class MediaAttachment < ApplicationRecord } elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type IMAGE_STYLES + elsif AUDIO_MIME_TYPES.include? f.instance.file_content_type + AUDIO_STYLES else VIDEO_STYLES end @@ -120,6 +139,8 @@ class MediaAttachment < ApplicationRecord [:gif_transcoder] elsif VIDEO_MIME_TYPES.include? f.file_content_type [:video_transcoder] + elsif AUDIO_MIME_TYPES.include? f.file_content_type + [:audio_transcoder] else [:thumbnail] end @@ -144,8 +165,8 @@ class MediaAttachment < ApplicationRecord end def set_type_and_extension - self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image - extension = appropriate_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 basename = Paperclip::Interpolations.basename(file, :original) file.instance_write :file_name, [basename, extension].delete_if(&:blank?).join('.') end diff --git a/app/models/mention.rb b/app/models/mention.rb index 3700c781c..14533e6a9 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -3,11 +3,11 @@ # # Table name: mentions # +# id :integer not null, primary key # 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 6e64848c7..ca984641a 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -3,11 +3,12 @@ # # Table name: mutes # -# 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 +# id :integer not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# hide_notifications :boolean default(TRUE), not null +# account_id :integer not null +# target_account_id :integer not null # class Mute < ApplicationRecord diff --git a/app/models/notification.rb b/app/models/notification.rb index 0a5d987cf..a3ffb1f45 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -4,11 +4,11 @@ # Table name: notifications # # 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 +# account_id :integer # from_account_id :integer # diff --git a/app/models/report.rb b/app/models/report.rb index bffb42b48..c36f8db0a 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -3,6 +3,7 @@ # # Table name: reports # +# id :integer not null, primary key # status_ids :integer default([]), not null, is an Array # comment :text default(""), not null # action_taken :boolean default(FALSE), not null @@ -10,7 +11,6 @@ # 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 # diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index c1645223b..d19489b36 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -4,24 +4,24 @@ # Table name: session_activations # # id :integer not null, primary key -# user_id :integer not null # session_id :string not null # created_at :datetime not null # updated_at :datetime not null # user_agent :string default(""), not null # ip :inet # access_token_id :integer +# user_id :integer not null # web_push_subscription_id :integer # -# id :integer not null, primary key -# user_id :integer not null +# id :bigint not null, primary key +# user_id :bigint not null # session_id :string not null # created_at :datetime not null # updated_at :datetime not null # user_agent :string default(""), not null # ip :inet -# access_token_id :integer +# access_token_id :bigint # class SessionActivation < ApplicationRecord diff --git a/app/models/setting.rb b/app/models/setting.rb index a14f156a1..df93590ce 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -3,12 +3,12 @@ # # Table name: settings # +# id :integer not null, primary key # var :string not null # value :text # thing_type :string # created_at :datetime # updated_at :datetime -# id :integer not null, primary key # thing_id :integer # diff --git a/app/models/status.rb b/app/models/status.rb index 5a7245613..172d3a665 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -5,7 +5,6 @@ # # id :integer not null, primary key # uri :string -# account_id :integer not null # text :text default(""), not null # created_at :datetime not null # updated_at :datetime not null @@ -14,8 +13,6 @@ # url :string # sensitive :boolean default(FALSE), not null # visibility :integer default("public"), not null -# in_reply_to_account_id :integer -# application_id :integer # spoiler_text :text default(""), not null # reply :boolean default(FALSE), not null # favourites_count :integer default(0), not null @@ -23,6 +20,9 @@ # language :string # conversation_id :integer # local :boolean +# account_id :integer not null +# application_id :integer +# in_reply_to_account_id :integer # class Status < ApplicationRecord @@ -154,6 +154,14 @@ class Status < ApplicationRecord where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private]) end + def as_direct_timeline(account) + query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}") + .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}") + .where(visibility: [:direct]) + + apply_timeline_filters(query, account, false) + end + def as_public_timeline(account = nil, local_only = false) query = timeline_scope(local_only).without_replies @@ -261,6 +269,11 @@ class Status < ApplicationRecord end end + def local_only? + # match both with and without U+FE0F (the emoji variation selector) + /👁\ufe0f?\z/.match?(content) + end + private def store_uri diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb index b51fe9ad7..36fe487dc 100644 --- a/app/models/stream_entry.rb +++ b/app/models/stream_entry.rb @@ -3,13 +3,13 @@ # # Table name: stream_entries # +# id :integer not null, primary key # 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 @@ -27,7 +27,7 @@ class StreamEntry < ApplicationRecord scope :recent, -> { reorder(id: :desc) } scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) } - delegate :target, :title, :content, :thread, + delegate :target, :title, :content, :thread, :local_only?, to: :status, allow_nil: true diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 39860196b..7f2eeab91 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -3,6 +3,7 @@ # # Table name: subscriptions # +# id :integer not null, primary key # callback_url :string default(""), not null # secret :string # expires_at :datetime @@ -12,7 +13,6 @@ # 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/user.rb b/app/models/user.rb index 325e27f44..b9b228c00 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,7 +5,6 @@ # # id :integer not null, primary key # email :string default(""), not null -# account_id :integer not null # created_at :datetime not null # updated_at :datetime not null # encrypted_password :string default(""), not null @@ -31,10 +30,14 @@ # last_emailed_at :datetime # otp_backup_codes :string is an Array # filtered_languages :string default([]), not null, is an Array +# account_id :integer not null +# disabled :boolean default(FALSE), not null +# moderator :boolean default(FALSE), not null # class User < ApplicationRecord include Settings::Extend + ACTIVE_DURATION = 14.days devise :registerable, :recoverable, @@ -51,8 +54,10 @@ class User < ApplicationRecord validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? validates_with BlacklistedEmailValidator, if: :email_changed? - scope :recent, -> { order(id: :desc) } - scope :admins, -> { where(admin: true) } + scope :recent, -> { order(id: :desc) } + scope :admins, -> { where(admin: true) } + scope :moderators, -> { where(moderator: true) } + scope :staff, -> { admins.or(moderators) } scope :confirmed, -> { where.not(confirmed_at: nil) } scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) } scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended: false }) } @@ -68,54 +73,71 @@ class User < ApplicationRecord has_many :session_activations, dependent: :destroy + delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, + :reduce_motion, :system_font_ui, :noindex, :theme, + to: :settings, prefix: :setting, allow_nil: false + def confirmed? confirmed_at.present? end - def disable_two_factor! - self.otp_required_for_login = false - otp_backup_codes&.clear - save! - end - - def setting_default_privacy - settings.default_privacy || (account.locked? ? 'private' : 'public') + def staff? + admin? || moderator? end - def setting_default_sensitive - settings.default_sensitive + def role + if admin? + 'admin' + elsif moderator? + 'moderator' + else + 'user' + end end - def setting_unfollow_modal - settings.unfollow_modal + def disable! + update!(disabled: true, + last_sign_in_at: current_sign_in_at, + current_sign_in_at: nil) end - def setting_boost_modal - settings.boost_modal + def enable! + update!(disabled: false) end - def setting_delete_modal - settings.delete_modal + def confirm! + skip_confirmation! + save! end - def setting_auto_play_gif - settings.auto_play_gif + def promote! + if moderator? + update!(moderator: false, admin: true) + elsif !admin? + update!(moderator: true) + end end - def setting_reduce_motion - settings.reduce_motion + def demote! + if admin? + update!(admin: false, moderator: true) + elsif moderator? + update!(moderator: false) + end end - def setting_system_font_ui - settings.system_font_ui + def disable_two_factor! + self.otp_required_for_login = false + otp_backup_codes&.clear + save! end - def setting_noindex - settings.noindex + def active_for_authentication? + super && !disabled? end - def setting_theme - settings.theme + def setting_default_privacy + settings.default_privacy || (account.locked? ? 'private' : 'public') end def token_for_app(a) diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb index cb15dfa37..5aee92d27 100644 --- a/app/models/web/push_subscription.rb +++ b/app/models/web/push_subscription.rb @@ -24,12 +24,12 @@ class Web::PushSubscription < ApplicationRecord end def pushable?(notification) - data && data.key?('alerts') && data['alerts'][notification.type.to_s] + data&.key?('alerts') && data['alerts'][notification.type.to_s] end def as_payload payload = { id: id, endpoint: endpoint } - payload[:alerts] = data['alerts'] if data && data.key?('alerts') + payload[:alerts] = data['alerts'] if data&.key?('alerts') payload end diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb index 1b0bfb2b7..12b9d1226 100644 --- a/app/models/web/setting.rb +++ b/app/models/web/setting.rb @@ -3,10 +3,10 @@ # # Table name: web_settings # +# id :integer not null, primary key # data :json # created_at :datetime not null # updated_at :datetime not null -# id :integer not null, primary key # user_id :integer # diff --git a/app/policies/account_moderation_note_policy.rb b/app/policies/account_moderation_note_policy.rb new file mode 100644 index 000000000..885411a5b --- /dev/null +++ b/app/policies/account_moderation_note_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AccountModerationNotePolicy < ApplicationPolicy + def create? + staff? + end + + def destroy? + admin? || owner? + end + + private + + def owner? + record.account_id == current_account&.id + end +end diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb new file mode 100644 index 000000000..85e2c8419 --- /dev/null +++ b/app/policies/account_policy.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class AccountPolicy < ApplicationPolicy + def index? + staff? + end + + def show? + staff? + end + + def suspend? + staff? && !record.user&.staff? + end + + def unsuspend? + staff? + end + + def silence? + staff? && !record.user&.staff? + end + + def unsilence? + staff? + end + + def redownload? + admin? + end + + def subscribe? + admin? + end + + def unsubscribe? + admin? + end + + def memorialize? + admin? && !record.user&.admin? + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 000000000..3e617001f --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :current_account, :record + + def initialize(current_account, record) + @current_account = current_account + @record = record + end + + delegate :admin?, :moderator?, :staff?, to: :current_user, allow_nil: true + + private + + def current_user + current_account&.user + end +end diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb new file mode 100644 index 000000000..a8c3cbc73 --- /dev/null +++ b/app/policies/custom_emoji_policy.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CustomEmojiPolicy < ApplicationPolicy + def index? + staff? + end + + def create? + admin? + end + + def update? + admin? + end + + def copy? + admin? + end + + def enable? + staff? + end + + def disable? + staff? + end + + def destroy? + admin? + end +end diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb new file mode 100644 index 000000000..47c0a81af --- /dev/null +++ b/app/policies/domain_block_policy.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class DomainBlockPolicy < ApplicationPolicy + def index? + admin? + end + + def show? + admin? + end + + def create? + admin? + end + + def destroy? + admin? + end +end diff --git a/app/policies/email_domain_block_policy.rb b/app/policies/email_domain_block_policy.rb new file mode 100644 index 000000000..5a75ee183 --- /dev/null +++ b/app/policies/email_domain_block_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class EmailDomainBlockPolicy < ApplicationPolicy + def index? + admin? + end + + def create? + admin? + end + + def destroy? + admin? + end +end diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb new file mode 100644 index 000000000..d1956e2de --- /dev/null +++ b/app/policies/instance_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class InstancePolicy < ApplicationPolicy + def index? + admin? + end + + def resubscribe? + admin? + end +end diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb new file mode 100644 index 000000000..95b5c30c8 --- /dev/null +++ b/app/policies/report_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ReportPolicy < ApplicationPolicy + def update? + staff? + end + + def index? + staff? + end + + def show? + staff? + end +end diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb new file mode 100644 index 000000000..2dcb79f51 --- /dev/null +++ b/app/policies/settings_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SettingsPolicy < ApplicationPolicy + def update? + admin? + end + + def show? + admin? + end +end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 2ded61850..369ede2b0 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -1,20 +1,19 @@ # frozen_string_literal: true -class StatusPolicy - attr_reader :account, :status - - def initialize(account, status) - @account = account - @status = status +class StatusPolicy < ApplicationPolicy + def index? + staff? end def show? + return false if local_only? && current_account.nil? + if direct? - owned? || status.mentions.where(account: account).exists? + owned? || record.mentions.where(account: current_account).exists? elsif private? - owned? || account&.following?(status.account) || status.mentions.where(account: account).exists? + owned? || current_account&.following?(author) || record.mentions.where(account: current_account).exists? else - account.nil? || !status.account.blocking?(account) + current_account.nil? || !author.blocking?(current_account) end end @@ -23,26 +22,34 @@ class StatusPolicy end def destroy? - admin? || owned? + staff? || owned? end alias unreblog? destroy? - private - - def admin? - account&.user&.admin? + def update? + staff? end + private + def direct? - status.direct_visibility? + record.direct_visibility? end def owned? - status.account.id == account&.id + author.id == current_account&.id end def private? - status.private_visibility? + record.private_visibility? + end + + def author + record.account + end + + def local_only? + record.local_only? end end diff --git a/app/policies/subscription_policy.rb b/app/policies/subscription_policy.rb new file mode 100644 index 000000000..ac9a8a6c4 --- /dev/null +++ b/app/policies/subscription_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SubscriptionPolicy < ApplicationPolicy + def index? + admin? + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 000000000..aae207d06 --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class UserPolicy < ApplicationPolicy + def reset_password? + staff? && !record.staff? + end + + def disable_2fa? + admin? && !record.staff? + end + + def confirm? + staff? && !record.confirmed? + end + + def enable? + admin? + end + + def disable? + admin? && !record.admin? + end + + def promote? + admin? && promoteable? + end + + def demote? + admin? && !record.admin? && demoteable? + end + + private + + def promoteable? + !record.staff? || !record.admin? + end + + def demoteable? + record.staff? + end +end diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 4c1124d59..1c08fb3bc 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -32,6 +32,15 @@ class InstancePresenter Mastodon::Version end + def commit_hash + current_release_file = Pathname.new('CURRENT_RELEASE').expand_path + if current_release_file.file? + IO.read(current_release_file).strip! + else + '' + end + end + def source_url Mastodon::Version.source_url end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index a8db73161..9dfa019f5 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -2,12 +2,17 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, - :media_attachments, :settings, :push_subscription + :media_attachments, :settings, :push_subscription, + :max_toot_chars has_many :custom_emojis, serializer: REST::CustomEmojiSerializer + def max_toot_chars + StatusLengthValidator::MAX_CHARS + end + def custom_emojis - CustomEmoji.local + CustomEmoji.local.where(disabled: false) end def meta @@ -53,6 +58,6 @@ class InitialStateSerializer < ActiveModel::Serializer end def media_attachments - { accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES } + { accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::AUDIO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES } end end diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb index b958e6a5d..65686a866 100644 --- a/app/serializers/rest/custom_emoji_serializer.rb +++ b/app/serializers/rest/custom_emoji_serializer.rb @@ -3,7 +3,7 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer include RoutingHelper - attributes :shortcode, :url, :static_url + attributes :shortcode, :url, :static_url, :visible_in_picker def url full_asset_url(object.image.url) diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 2898011fd..abbacc374 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -4,7 +4,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer include RoutingHelper attributes :uri, :title, :description, :email, - :version, :urls, :stats, :thumbnail + :version, :urls, :stats, :thumbnail, :max_toot_chars def uri Rails.configuration.x.local_domain @@ -30,6 +30,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer full_asset_url(instance_presenter.thumbnail.file.url) if instance_presenter.thumbnail end + def max_toot_chars + StatusLengthValidator::MAX_CHARS + end + def stats { user_count: instance_presenter.user_count, diff --git a/app/serializers/rest/list_serializer.rb b/app/serializers/rest/list_serializer.rb new file mode 100644 index 000000000..c0150888e --- /dev/null +++ b/app/serializers/rest/list_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::ListSerializer < ActiveModel::Serializer + attributes :id, :title +end diff --git a/app/serializers/rest/mute_serializer.rb b/app/serializers/rest/mute_serializer.rb new file mode 100644 index 000000000..043a2f059 --- /dev/null +++ b/app/serializers/rest/mute_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class REST::MuteSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :id, :account, :target_account, :created_at, :hide_notifications + + def account + REST::AccountSerializer.new(object.account) + end + + def target_account + REST::AccountSerializer.new(object.target_account) + end +end \ No newline at end of file diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index e2a89a87c..8d7b7a17c 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -16,7 +16,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService 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, id: true) if actor.nil? + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? || needs_update(actor) return if actor.suspended? @@ -44,4 +44,8 @@ class ActivityPub::FetchRemoteStatusService < BaseService def expected_type? %w(Note Article).include? @json['type'] end + + def needs_update(actor) + actor.possibly_stale? + end end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 5d83771c9..21c775208 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -26,10 +26,11 @@ class BatchedRemoveStatusService < BaseService statuses.each(&:destroy) # Batch by source account - statuses.group_by(&:account_id).each do |_, account_statuses| + statuses.group_by(&:account_id).each_value do |account_statuses| account = account_statuses.first.account unpush_from_home_timelines(account, account_statuses) + unpush_from_list_timelines(account, account_statuses) if account.local? batch_stream_entries(account, account_statuses) @@ -40,6 +41,7 @@ class BatchedRemoveStatusService < BaseService # Cannot be batched statuses.each do |status| unpush_from_public_timelines(status) + unpush_from_direct_timelines(status) if status.direct_visibility? batch_salmon_slaps(status) if status.local? end @@ -79,7 +81,15 @@ class BatchedRemoveStatusService < BaseService recipients.each do |follower| statuses.each do |status| - FeedManager.instance.unpush(:home, follower, status) + FeedManager.instance.unpush_from_home(follower, status) + end + end + end + + def unpush_from_list_timelines(account, statuses) + account.lists.select(:id, :account_id).each do |list| + statuses.each do |status| + FeedManager.instance.unpush_from_list(list, status) end end end @@ -100,6 +110,16 @@ class BatchedRemoveStatusService < BaseService end end + def unpush_from_direct_timelines(status) + payload = @json_payloads[status.id] + redis.pipelined do + @mentions[status.id].each do |mention| + redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local? + end + redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local? + end + end + def batch_salmon_slaps(status) return if @mentions[status.id].empty? diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 47a47a735..0f77556dc 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -10,15 +10,18 @@ class FanOutOnWriteService < BaseService deliver_to_self(status) if status.account.local? + render_anonymous_payload(status) + if status.direct_visibility? deliver_to_mentioned_followers(status) + deliver_to_direct_timelines(status) else deliver_to_followers(status) + deliver_to_lists(status) end return if status.account.silenced? || !status.public_visibility? || status.reblog? - render_anonymous_payload(status) deliver_to_hashtags(status) return if status.reply? && status.in_reply_to_account_id != status.account_id @@ -30,7 +33,7 @@ class FanOutOnWriteService < BaseService def deliver_to_self(status) Rails.logger.debug "Delivering status #{status.id} to author" - FeedManager.instance.push(:home, status.account, status) + FeedManager.instance.push_to_home(status.account, status) end def deliver_to_followers(status) @@ -38,7 +41,17 @@ class FanOutOnWriteService < BaseService status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |followers| FeedInsertWorker.push_bulk(followers) do |follower| - [status.id, follower.id] + [status.id, follower.id, :home] + end + end + end + + def deliver_to_lists(status) + Rails.logger.debug "Delivering status #{status.id} to lists" + + status.account.lists.joins(account: :user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |lists| + FeedInsertWorker.push_bulk(lists) do |list| + [status.id, list.id, :list] end end end @@ -49,7 +62,7 @@ class FanOutOnWriteService < BaseService status.mentions.includes(:account).each do |mention| mentioned_account = mention.account next if !mentioned_account.local? || !mentioned_account.following?(status.account) || FeedManager.instance.filter?(:home, status, mention.account_id) - FeedManager.instance.push(:home, mentioned_account, status) + FeedManager.instance.push_to_home(mentioned_account, status) end end @@ -73,4 +86,13 @@ class FanOutOnWriteService < BaseService Redis.current.publish('timeline:public', @payload) Redis.current.publish('timeline:public:local', @payload) if status.local? end + + def deliver_to_direct_timelines(status) + Rails.logger.debug "Delivering status #{status.id} to direct timelines" + + status.mentions.includes(:account).each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local? + end end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 791773f25..20579ca63 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -6,25 +6,38 @@ class FollowService < BaseService # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) - def call(source_account, uri) + # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true + def call(source_account, uri, reblogs: nil) + reblogs = true if reblogs.nil? target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) - return if source_account.following?(target_account) || source_account.requested?(target_account) + if source_account.following?(target_account) + # We're already following this account, but we'll call follow! again to + # make sure the reblogs status is set correctly. + source_account.follow!(target_account, reblogs: reblogs) + return + elsif source_account.requested?(target_account) + # This isn't managed by a method in AccountInteractions, so we modify it + # ourselves if necessary. + req = follow_requests.find_by(target_account: other_account) + req.update!(show_reblogs: reblogs) + return + end if target_account.locked? || target_account.activitypub? - request_follow(source_account, target_account) + request_follow(source_account, target_account, reblogs: reblogs) else - direct_follow(source_account, target_account) + direct_follow(source_account, target_account, reblogs: reblogs) end end private - def request_follow(source_account, target_account) - follow_request = FollowRequest.create!(account: source_account, target_account: target_account) + def request_follow(source_account, target_account, reblogs: true) + follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow_request) @@ -38,8 +51,8 @@ class FollowService < BaseService follow_request end - def direct_follow(source_account, target_account) - follow = source_account.follow!(target_account) + def direct_follow(source_account, target_account, reblogs: true) + follow = source_account.follow!(target_account, reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow) diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 132369484..547b2efa1 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account) + def call(account, target_account, notifications: nil) return if account.id == target_account.id - mute = account.mute!(target_account) + mute = account.mute!(target_account, notifications: notifications) BlockWorker.perform_async(account.id, target_account.id) mute end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index ca53c61c5..d5960c3ad 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -29,24 +29,65 @@ class NotifyService < BaseService end def blocked_reblog? - false + @recipient.muting_reblogs?(@notification.from_account) end def blocked_follow_request? false end + def following_sender? + return @following_sender if defined?(@following_sender) + @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account) + end + + def optional_non_follower? + @recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient) + end + + def optional_non_following? + @recipient.user.settings.interactions['must_be_following'] && !following_sender? + end + + def direct_message? + @notification.type == :mention && @notification.target_status.direct_visibility? + end + + def response_to_recipient? + @notification.target_status.in_reply_to_account_id == @recipient.id + end + + def optional_non_following_and_direct? + direct_message? && + @recipient.user.settings.interactions['must_be_following_dm'] && + !following_sender? && + !response_to_recipient? + end + + def hellbanned? + @notification.from_account.silenced? && !following_sender? + end + + def from_self? + @recipient.id == @notification.from_account.id + end + + def domain_blocking? + @recipient.domain_blocking?(@notification.from_account.domain) && !following_sender? + end + def blocked? - blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway - blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self - blocked ||= @recipient.domain_blocking?(@notification.from_account.domain) && !@recipient.following?(@notification.from_account) # Skip for domain blocked accounts - blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts - blocked ||= @recipient.muting?(@notification.from_account) # Skip for muted accounts - blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban - blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options - blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options + blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway + blocked ||= from_self? # Skip for interactions with self + blocked ||= domain_blocking? # Skip for domain blocked accounts + blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts + blocked ||= @recipient.muting_notifications?(@notification.from_account) + blocked ||= hellbanned? # Hellban + blocked ||= optional_non_follower? # Options + blocked ||= optional_non_following? # Options + blocked ||= optional_non_following_and_direct? # Options blocked ||= conversation_muted? - blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters + blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters blocked end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index e37cd94df..974c586f2 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -39,9 +39,13 @@ class PostStatusService < BaseService LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? DistributionWorker.perform_async(status.id) - Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) - ActivityPub::DistributionWorker.perform_async(status.id) - ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local? + + # match both with and without U+FE0F (the emoji variation selector) + unless /👁\ufe0f?\z/.match?(status.content) + Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(status.id) + ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local? + end if options[:idempotency].present? redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) @@ -70,11 +74,11 @@ class PostStatusService < BaseService end def process_mentions_service - @process_mentions_service ||= ProcessMentionsService.new + ProcessMentionsService.new end def process_hashtags_service - @process_hashtags_service ||= ProcessHashtagsService.new + ProcessHashtagsService.new end def redis diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 1c3eea369..a229d4ff8 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -10,23 +10,26 @@ class ProcessMentionsService < BaseService def call(status) return unless status.local? - status.text.scan(Account::MENTION_RE).each do |match| - username, domain = match.first.split('@') - mentioned_account = Account.find_remote(username, domain) - - if mentioned_account.nil? && !domain.nil? - begin - mentioned_account = follow_remote_account_service.call(match.first.to_s) - rescue Goldfinger::Error, HTTP::Error - mentioned_account = nil - end + status.text = status.text.gsub(Account::MENTION_RE) do |match| + begin + mentioned_account = resolve_remote_account_service.call($1) + rescue Goldfinger::Error, HTTP::Error + mentioned_account = nil end - next if mentioned_account.nil? + if mentioned_account.nil? + username, domain = match.first.split('@') + mentioned_account = Account.find_remote(username, domain) + end + + next match if mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus? && status.stream_entry.hidden?) mentioned_account.mentions.where(status: status).first_or_create(status: status) + "@#{mentioned_account.acct}" end + status.save! + status.mentions.includes(:account).each do |mention| create_notification(status, mention) end @@ -54,7 +57,7 @@ class ProcessMentionsService < BaseService ).as_json).sign!(status.account)) end - def follow_remote_account_service - @follow_remote_account_service ||= ResolveRemoteAccountService.new + def resolve_remote_account_service + ResolveRemoteAccountService.new end end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 3c4e5847f..52e3ba0e0 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -20,8 +20,11 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '') DistributionWorker.perform_async(reblog.id) - Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) - ActivityPub::DistributionWorker.perform_async(reblog.id) + + unless /👁$/.match?(reblogged_status.content) + Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(reblog.id) + end create_notification(reblog) reblog diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 96d9208cc..9617081fd 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -14,10 +14,12 @@ class RemoveStatusService < BaseService remove_from_self if status.account.local? remove_from_followers + remove_from_lists remove_from_affected remove_reblogs remove_from_hashtags remove_from_public + remove_from_direct if status.direct_visibility? @status.destroy! @@ -30,12 +32,18 @@ class RemoveStatusService < BaseService private def remove_from_self - unpush(:home, @account, @status) + FeedManager.instance.unpush_from_home(@account, @status) end def remove_from_followers @account.followers.local.find_each do |follower| - unpush(:home, follower, @status) + FeedManager.instance.unpush_from_home(follower, @status) + end + end + + def remove_from_lists + @account.lists.select(:id, :account_id).find_each do |list| + FeedManager.instance.unpush_from_list(list, @status) end end @@ -101,10 +109,6 @@ class RemoveStatusService < BaseService end end - def unpush(type, receiver, status) - FeedManager.instance.unpush(type, receiver, status) - end - def remove_from_hashtags return unless @status.public_visibility? @@ -121,6 +125,13 @@ class RemoveStatusService < BaseService Redis.current.publish('timeline:public:local', @payload) if @status.local? end + def remove_from_direct + @mentions.each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local? + end + def redis Redis.current end diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index 3d0a36f6c..3293fe40f 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -124,11 +124,11 @@ class ResolveRemoteAccountService < BaseService end def auto_suspend? - domain_block && domain_block.suspend? + domain_block&.suspend? end def auto_silence? - domain_block && domain_block.silence? + domain_block&.silence? end def domain_block diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 983c5495b..5b37ba9ba 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -1,22 +1,27 @@ # frozen_string_literal: true class SuspendAccountService < BaseService - def call(account, remove_user = false) + def call(account, options = {}) @account = account + @options = options - purge_user if remove_user - purge_profile - purge_content - unsubscribe_push_subscribers + purge_user! + purge_profile! + purge_content! + unsubscribe_push_subscribers! end private - def purge_user - @account.user.destroy + def purge_user! + if @options[:remove_user] + @account.user&.destroy + else + @account.user&.disable! + end end - def purge_content + def purge_content! @account.statuses.reorder(nil).find_in_batches do |statuses| BatchedRemoveStatusService.new.call(statuses) end @@ -33,7 +38,7 @@ class SuspendAccountService < BaseService end end - def purge_profile + def purge_profile! @account.suspended = true @account.display_name = '' @account.note = '' @@ -42,7 +47,7 @@ class SuspendAccountService < BaseService @account.save! end - def unsubscribe_push_subscribers + def unsubscribe_push_subscribers! destroy_all(@account.subscriptions) end diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb index 77be3f1f5..79d17742a 100644 --- a/app/validators/status_length_validator.rb +++ b/app/validators/status_length_validator.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class StatusLengthValidator < ActiveModel::Validator - MAX_CHARS = 500 + MAX_CHARS = (ENV['MAX_TOOT_CHARS'] || 500).to_i def validate(status) return unless status.local? && !status.reblog? diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index b012606ce..7ffa5ecc3 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -55,4 +55,8 @@ .container %p = link_to t('about.source_code'), @instance_presenter.source_url - = " (#{@instance_presenter.version_number})" + - if @instance_presenter.commit_hash == "" + %strong= " (#{@instance_presenter.version_number})" + - else + %strong= "#{@instance_presenter.version_number}, " + %strong= "#{@instance_presenter.commit_hash}" diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index f8f90ce24..385b0b1dc 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -47,6 +47,13 @@ %p= t('about.closed_registrations') - else = @instance_presenter.closed_registrations_message.html_safe + + = simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) 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' } + + .actions + = f.button :button, t('auth.login'), type: :submit = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block' .about-short @@ -68,4 +75,8 @@ .container %p = link_to t('about.source_code'), @instance_presenter.source_url - = " (#{@instance_presenter.version_number})" + - if @instance_presenter.commit_hash == "" + %strong= " (#{@instance_presenter.version_number})" + - else + %strong= " (#{@instance_presenter.version_number}, " + %strong= " #{@instance_presenter.commit_hash})" diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 08c3891d2..94ec5ae5b 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,21 +1,23 @@ +- processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" } .card__illustration - - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account) - .controls - - if current_account.following?(account) - = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do - = fa_icon 'user-times' - = t('accounts.unfollow') - - else - = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do - = fa_icon 'user-plus' - = t('accounts.follow') - - elsif !user_signed_in? - .controls - .remote-follow - = link_to account_remote_follow_path(account), class: 'icon-button' do - = fa_icon 'user-plus' - = t('accounts.remote_follow') + - unless account.memorial? + - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account) + .controls + - if current_account.following?(account) + = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-times' + = t('accounts.unfollow') + - else + = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.follow') + - elsif !user_signed_in? + .controls + .remote-follow + = link_to account_remote_follow_path(account), class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.remote_follow') .avatar= image_tag account.avatar.url(:original), class: 'u-photo' @@ -28,11 +30,20 @@ - if account.user_admin? .roles - .account-role + .account-role.admin = t 'accounts.roles.admin' - + - elsif account.user_moderator? + .roles + .account-role.moderator + = t 'accounts.roles.moderator' .bio - .account__header__content.p-note.emojify= Formatter.instance.simplified_format(account) + .account__header__content.p-note.emojify!=processed_bio[:text] + - if processed_bio[:metadata].length > 0 + %table.metadata< + - processed_bio[:metadata].each do |i| + %tr.metadata-item>< + %th.emojify>!=i[0] + %td.emojify>!=i[1] .details-counters .counter{ class: active_nav_class(short_account_url(account)) } diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 6c90b2c04..fd8ad5530 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -12,7 +12,9 @@ = opengraph 'og:type', 'profile' = render 'og', account: @account, url: short_account_url(@account, only_path: false) -- if show_landing_strip? +- if @account.memorial? + .memoriam-strip= t('in_memoriam_html') +- elsif show_landing_strip? = render partial: 'shared/landing_strip', locals: { account: @account } .h-feed 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 index 4651630e9..6761a4319 100644 --- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml +++ b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml @@ -7,4 +7,4 @@ %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 + = link_to t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note) diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 1b56a3a31..27a0682d8 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -42,7 +42,7 @@ - if params[key].present? = hidden_field_tag key, params[key] - - %i(username display_name email ip).each do |key| + - %i(username by_domain display_name email ip).each do |key| .input.string.optional = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}") diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 1f5c8fcf5..ddb1cf15d 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -16,8 +16,27 @@ - if @account.local? %tr + %th= t('admin.accounts.role') + %td + = t("admin.accounts.roles.#{@account.user&.role}") + = table_link_to 'angle-double-up', t('admin.accounts.promote'), promote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:promote, @account.user) + = table_link_to 'angle-double-down', t('admin.accounts.demote'), demote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:demote, @account.user) + %tr %th= t('admin.accounts.email') - %td= @account.user_email + %td + = @account.user_email + + - if @account.user_confirmed? + = fa_icon('check') + %tr + %th= t('admin.accounts.login_status') + %td + - if @account.user&.disabled? + = t('admin.accounts.disabled') + = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user) + - else + = t('admin.accounts.enabled') + = table_link_to 'lock', t('admin.accounts.disable'), disable_admin_account_path(@account.id), method: :post if can?(:disable, @account.user) %tr %th= t('admin.accounts.most_recent_ip') %td= @account.user_current_sign_in_ip @@ -62,26 +81,28 @@ %div{ style: 'overflow: hidden' } %div{ style: 'float: right' } - if @account.local? - = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' + = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user) - if @account.user&.otp_required_for_login? - = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' + = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user) + - unless @account.memorial? + = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:memorialize, @account) - else - = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' + = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account) %div{ style: 'float: left' } - if @account.silenced? - = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button' + = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button' if can?(:unsilence, @account) - else - = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button' + = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button' if can?(:silence, @account) - if @account.local? - unless @account.user_confirmed? - = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' + = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user) - if @account.suspended? - = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button' + = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button' if can?(:unsuspend, @account) - else - = link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' + = link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:suspend, @account) - unless @account.local? %hr @@ -107,9 +128,9 @@ %div{ style: 'overflow: hidden' } %div{ style: 'float: right' } - = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button' + = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button' if can?(:subscribe, @account) - if @account.subscribed? - = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' + = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' if can?(:unsubscribe, @account) %hr %h3 ActivityPub diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index 1fa64908c..bab34bc8d 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -1,6 +1,6 @@ %tr %td - = image_tag custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:" + = custom_emoji_tag(custom_emoji) %td %samp= ":#{custom_emoji.shortcode}:" %td @@ -9,8 +9,16 @@ - 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, page: params[:page]), method: :post + - if custom_emoji.local? + - if custom_emoji.visible_in_picker + = table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }), method: :patch + - else + = table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }), method: :patch + - else + - if custom_emoji.local_counterpart.present? + = link_to safe_join([custom_emoji_tag(custom_emoji.local_counterpart), t('admin.custom_emojis.overwrite')]), copy_admin_custom_emoji_path(custom_emoji, page: params[:page]), method: :post, class: 'table-action-link' + - else + = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page]), 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') } diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 659295ebf..63b3a0c26 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,14 +1,12 @@ - content_for :header_tags do - %link{ href: asset_pack_path('features/getting_started.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/compose.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/home_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + - if theme_data['preload'] + - theme_data['preload'].each do |link| + %link{ href: asset_pack_path("#{link}.js"), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) - = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous' + = javascript_pack_tag "themes/#{current_theme}", integrity: true, crossorigin: 'anonymous' + = stylesheet_pack_tag "themes/#{current_theme}", integrity: true, media: 'all' .app-holder#mastodon{ data: { props: Oj.dump(default_props) } } %noscript diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 831858bcf..24b74c787 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -19,15 +19,16 @@ = title = stylesheet_pack_tag 'common', media: 'all' - = stylesheet_pack_tag current_theme, media: 'all' = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = csrf_meta_tags + - if controller_name != 'home' + = stylesheet_pack_tag 'application', integrity: true, media: 'all' + = yield :header_tags - body_classes ||= @body_classes || '' - - body_classes += ' reduce-motion' if current_account&.user&.setting_reduce_motion - body_classes += ' system-font' if current_account&.user&.setting_system_font_ui %body{ class: add_rtl_body_class(body_classes) } diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index ac11cfbe7..5fc60be17 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -5,8 +5,8 @@ %meta{ name: 'robots', content: 'noindex' }/ = stylesheet_pack_tag 'common', media: 'all' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all' = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' + = stylesheet_pack_tag 'application', integrity: true, media: 'all' = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' %body.embed diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml index 37359b89b..d0eae4434 100644 --- a/app/views/layouts/error.html.haml +++ b/app/views/layouts/error.html.haml @@ -6,7 +6,7 @@ %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 Setting.default_settings['theme'], media: 'all' + = stylesheet_pack_tag 'application', integrity: true, media: 'all' %body.error .dialog %img{ alt: Setting.default_settings['site_title'], src: '/oops.gif' }/ diff --git a/app/views/settings/applications/new.html.haml b/app/views/settings/applications/new.html.haml index 5274a430c..aa2281fea 100644 --- a/app/views/settings/applications/new.html.haml +++ b/app/views/settings/applications/new.html.haml @@ -3,6 +3,6 @@ = simple_form_for @application, url: settings_applications_path do |f| = render 'fields', f: f - + .actions = f.button :button, t('doorkeeper.applications.buttons.submit'), type: :submit diff --git a/app/views/settings/applications/show.html.haml b/app/views/settings/applications/show.html.haml index 12baed088..390682d6f 100644 --- a/app/views/settings/applications/show.html.haml +++ b/app/views/settings/applications/show.html.haml @@ -25,7 +25,7 @@ = simple_form_for @application, url: settings_application_path(@application), method: :put do |f| = render 'fields', f: f - + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/keyword_mutes/_fields.html.haml b/app/views/settings/keyword_mutes/_fields.html.haml new file mode 100644 index 000000000..892676f18 --- /dev/null +++ b/app/views/settings/keyword_mutes/_fields.html.haml @@ -0,0 +1,11 @@ +.fields-group + = f.input :keyword + = f.check_box :whole_word + = f.label :whole_word, t('keyword_mutes.match_whole_word') + +.actions + - if f.object.persisted? + = f.button :button, t('generic.save_changes'), type: :submit + = link_to t('keyword_mutes.remove'), settings_keyword_mute_path(f.object), class: 'negative button', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } + - else + = f.button :button, t('keyword_mutes.add_keyword'), type: :submit diff --git a/app/views/settings/keyword_mutes/_keyword_mute.html.haml b/app/views/settings/keyword_mutes/_keyword_mute.html.haml new file mode 100644 index 000000000..c45cc64fb --- /dev/null +++ b/app/views/settings/keyword_mutes/_keyword_mute.html.haml @@ -0,0 +1,10 @@ +%tr + %td + = keyword_mute.keyword + %td + - if keyword_mute.whole_word + %i.fa.fa-check + %td + = table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute) + %td + = table_link_to 'times', t('keyword_mutes.remove'), settings_keyword_mute_path(keyword_mute), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/settings/keyword_mutes/edit.html.haml b/app/views/settings/keyword_mutes/edit.html.haml new file mode 100644 index 000000000..af3949be2 --- /dev/null +++ b/app/views/settings/keyword_mutes/edit.html.haml @@ -0,0 +1,6 @@ +- content_for :page_title do + = t('keyword_mutes.edit_keyword') + += simple_form_for @keyword_mute, url: settings_keyword_mute_path(@keyword_mute), as: :keyword_mute do |f| + = render 'shared/error_messages', object: @keyword_mute + = render 'fields', f: f diff --git a/app/views/settings/keyword_mutes/index.html.haml b/app/views/settings/keyword_mutes/index.html.haml new file mode 100644 index 000000000..9ef8d55bc --- /dev/null +++ b/app/views/settings/keyword_mutes/index.html.haml @@ -0,0 +1,18 @@ +- content_for :page_title do + = t('settings.keyword_mutes') + +.table-wrapper + %table.table + %thead + %tr + %th= t('keyword_mutes.keyword') + %th= t('keyword_mutes.match_whole_word') + %th + %th + %tbody + = render partial: 'keyword_mute', collection: @keyword_mutes, as: :keyword_mute + += paginate @keyword_mutes +.simple_form + = link_to t('keyword_mutes.add_keyword'), new_settings_keyword_mute_path, class: 'button' + = link_to t('keyword_mutes.remove_all'), destroy_all_settings_keyword_mutes_path, class: 'button negative', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/settings/keyword_mutes/new.html.haml b/app/views/settings/keyword_mutes/new.html.haml new file mode 100644 index 000000000..5c999c8d2 --- /dev/null +++ b/app/views/settings/keyword_mutes/new.html.haml @@ -0,0 +1,6 @@ +- content_for :page_title do + = t('keyword_mutes.add_keyword') + += simple_form_for @keyword_mute, url: settings_keyword_mutes_path, as: :keyword_mute do |f| + = render 'shared/error_messages', object: @keyword_mute + = render 'fields', f: f diff --git a/app/views/settings/notifications/show.html.haml b/app/views/settings/notifications/show.html.haml index 80cd615c7..b718b62df 100644 --- a/app/views/settings/notifications/show.html.haml +++ b/app/views/settings/notifications/show.html.haml @@ -11,7 +11,7 @@ = 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 @@ -20,6 +20,7 @@ = 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 + = ff.input :must_be_following_dm, as: :boolean, wrapper: :with_label .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 7a06cd014..551a7ca49 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -6,7 +6,7 @@ .fields-group = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe - = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 160 - @account.note.size).html_safe + = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 500 - @account.note.size).html_safe .card.compact{ style: "background-image: url(#{@account.header.url(:original)})", data: { original_src: @account.header.url(:original) } } .avatar= image_tag @account.avatar.url(:original), data: { original_src: @account.avatar.url(:original) } diff --git a/app/views/stream_entries/_content_spoiler.html.haml b/app/views/stream_entries/_content_spoiler.html.haml index 798dfce67..fb42d3f57 100644 --- a/app/views/stream_entries/_content_spoiler.html.haml +++ b/app/views/stream_entries/_content_spoiler.html.haml @@ -1,4 +1,4 @@ -.media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' } +.media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' }>< .spoiler-button .icon-button.overlayed %i.fa.fa-fw.fa-eye diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 3119ebf4b..b488bd9ba 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -17,16 +17,16 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary> #{Formatter.instance.format_spoiler(status)} %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true) - - - if !status.media_attachments.empty? - - if status.media_attachments.first.video? - - video = status.media_attachments.first - %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }} - - else - %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }} - - elsif status.preview_cards.first - %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }} + .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< + = Formatter.instance.format(status, custom_emojify: true) + - if !status.media_attachments.empty? + - if status.media_attachments.first.video? + - video = status.media_attachments.first + %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }}< + - else + %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}< + - elsif status.preview_cards.first + %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}< .detailed-status__meta %data.dt-published{ value: status.created_at.to_time.iso8601 } diff --git a/app/views/stream_entries/_media.html.haml b/app/views/stream_entries/_media.html.haml index 779f02c8d..32d024cf6 100644 --- a/app/views/stream_entries/_media.html.haml +++ b/app/views/stream_entries/_media.html.haml @@ -1,4 +1,4 @@ -.media-item +.media-item>< = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : '', target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do - unless media.image? %video{ src: media.file.url(:original), autoplay: true, loop: true }/ diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index b594c9da6..0b45ff308 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -18,11 +18,12 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary> #{Formatter.instance.format_spoiler(status)} %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true) + .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< + = Formatter.instance.format(status, custom_emojify: true) - - unless status.media_attachments.empty? - - if status.media_attachments.first.video? - - video = status.media_attachments.first - %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 610, height: 343) }} - - else - %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }} + - unless status.media_attachments.empty? + - if status.media_attachments.first.video? + - video = status.media_attachments.first + %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 610, height: 343) }}>< + - else + %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}>< diff --git a/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb b/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb index 80edcfda7..0be16d994 100644 --- a/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb +++ b/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb @@ -1,6 +1,6 @@ <p>Boas vindas, <%= @resource.email %>!</p> -<p>Você acabou de criar uma conta no <%= @instance %>.</p> +<p>Você acabou de criar uma conta na instância <%= @instance %>.</p> <p>Para confirmar o seu cadastro, por favor clique no link a seguir: <br> <%= link_to 'Confirmar cadastro', confirmation_url(@resource, confirmation_token: @token) %> @@ -9,4 +9,4 @@ <p>Atenciosamente,<p> -<p>A equipe do <%= @instance %></p> +<p>A equipe da instância <%= @instance %></p> diff --git a/app/views/user_mailer/confirmation_instructions.pt-BR.text.erb b/app/views/user_mailer/confirmation_instructions.pt-BR.text.erb index 95efb3436..578f7acb5 100644 --- a/app/views/user_mailer/confirmation_instructions.pt-BR.text.erb +++ b/app/views/user_mailer/confirmation_instructions.pt-BR.text.erb @@ -1,6 +1,6 @@ Boas vindas, <%= @resource.email %>! -Você acabou de criar uma conta no <%= @instance %>. +Você acabou de criar uma conta na instância <%= @instance %>. Para confirmar o seu cadastro, por favor clique no link a seguir: <%= confirmation_url(@resource, confirmation_token: @token) %> @@ -9,4 +9,4 @@ Por favor, leia também os nossos termos e condições de uso <%= terms_url %> Atenciosamente, -A equipe do <%= @instance %> +A equipe da instância <%= @instance %> diff --git a/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb b/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb index de2f8b6e0..8a676498a 100644 --- a/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb +++ b/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb @@ -1,10 +1,13 @@ -<p><%= @resource.email %> ,嗨呀!</p> +<p><%= @resource.email %>,你好呀!</p> -<p>你刚刚在 <%= @instance %> 创建了帐号。</p> +<p>你刚刚在 <%= @instance %> 创建了一个帐户呢。</p> -<p>点击下面的链接来完成注册啦 : <br> +<p>点击下面的链接来完成注册啦:<br> <%= link_to '确认帐户', confirmation_url(@resource, confirmation_token: @token) %> -<p>别忘了看看 <%= link_to '使用条款', terms_url %>。</p> +<p>上面的链接按不动?把下面的链接复制到地址栏再试试:<br> +<span><%= confirmation_url(@resource, confirmation_token: @token) %></span> -<p> <%= @instance %> 敬上</p> \ No newline at end of file +<p>记得读一读我们的<%= link_to '使用条款', terms_url %>哦。</p> + +<p>来自 <%= @instance %> 管理团队</p> diff --git a/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb b/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb index d7d4b4b23..25d901f16 100644 --- a/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb +++ b/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb @@ -1,10 +1,10 @@ -<%= @resource.email %> ,嗨呀! +<%= @resource.email %>,你好呀! -你刚刚在 <%= @instance %> 创建了帐号。 +你刚刚在 <%= @instance %> 创建了一个帐户呢。 -点击下面的链接来完成注册啦 : <br> -<%= link_to '确认帐户', confirmation_url(@resource, confirmation_token: @token) %> +点击下面的链接来完成注册啦: +<%= confirmation_url(@resource, confirmation_token: @token) %> -别忘了看看 <%= link_to 'terms and conditions', terms_url %>。 +记得读一读我们的使用条款哦:<%= terms_url %> -<%= @instance %> 敬上 \ No newline at end of file +来自 <%= @instance %> 管理团队 \ No newline at end of file diff --git a/app/views/user_mailer/password_change.pt-BR.html.erb b/app/views/user_mailer/password_change.pt-BR.html.erb index 5f707ba09..a1aaa265e 100644 --- a/app/views/user_mailer/password_change.pt-BR.html.erb +++ b/app/views/user_mailer/password_change.pt-BR.html.erb @@ -1,3 +1,3 @@ <p>Olá, <%= @resource.email %>!</p> -<p>Estamos te contatando para te notificar que a senha senha no <%= @instance %> foi modificada.</p> +<p>Estamos te contatando para te notificar que a sua senha na instância <%= @instance %> foi modificada.</p> diff --git a/app/views/user_mailer/password_change.pt-BR.text.erb b/app/views/user_mailer/password_change.pt-BR.text.erb index d8b76648c..eb7368ba9 100644 --- a/app/views/user_mailer/password_change.pt-BR.text.erb +++ b/app/views/user_mailer/password_change.pt-BR.text.erb @@ -1,3 +1,3 @@ Olá, <%= @resource.email %>! -Estamos te contatando para te notificar que a senha senha no <%= @instance %> foi modificada. +Estamos te contatando para te notificar que a sua senha na instância <%= @instance %> foi modificada. diff --git a/app/views/user_mailer/password_change.zh-cn.html.erb b/app/views/user_mailer/password_change.zh-cn.html.erb index 115030af4..64e8b6b2f 100644 --- a/app/views/user_mailer/password_change.zh-cn.html.erb +++ b/app/views/user_mailer/password_change.zh-cn.html.erb @@ -1,3 +1,3 @@ -<p><%= @resource.email %>,嗨呀!</p> +<p><%= @resource.email %>,你好呀!</p> -<p>这只是一封用来通知你的密码已经被修改的邮件。_(:3」∠)_</p> +<p>提醒一下,你在 <%= @instance %> 上的密码被更改了哦。</p> diff --git a/app/views/user_mailer/password_change.zh-cn.text.erb b/app/views/user_mailer/password_change.zh-cn.text.erb index 5a989d324..dbc065173 100644 --- a/app/views/user_mailer/password_change.zh-cn.text.erb +++ b/app/views/user_mailer/password_change.zh-cn.text.erb @@ -1,3 +1,3 @@ -<%= @resource.email %>,嗨呀! +<%= @resource.email %>,你好呀! -这只是一封用来通知你的密码已经被修改的邮件。_(:3」∠)_ +提醒一下,你在 <%= @instance %> 上的密码被更改了哦。 diff --git a/app/views/user_mailer/reset_password_instructions.oc.html.erb b/app/views/user_mailer/reset_password_instructions.oc.html.erb index 6c775b3a1..92e4b8f8b 100644 --- a/app/views/user_mailer/reset_password_instructions.oc.html.erb +++ b/app/views/user_mailer/reset_password_instructions.oc.html.erb @@ -1,6 +1,6 @@ <p>Bonjorn <%= @resource.email %> !</p> -<p>Qualqu’un a demandat la reĩnicializacion de vòstre senhal per Mastodon. Podètz realizar la reĩnicializacion en clicant sul ligam çai-jos.</p> +<p>Qualqu’un a demandat la reïnicializacion de vòstre senhal per Mastodon. Podètz realizar la reïnicializacion en clicant sul ligam çai-jos.</p> <p><%= link_to 'Modificar mon senhal', edit_password_url(@resource, reset_password_token: @token) %></p> diff --git a/app/views/user_mailer/reset_password_instructions.oc.text.erb b/app/views/user_mailer/reset_password_instructions.oc.text.erb index 26432d2df..5a5219589 100644 --- a/app/views/user_mailer/reset_password_instructions.oc.text.erb +++ b/app/views/user_mailer/reset_password_instructions.oc.text.erb @@ -1,6 +1,6 @@ Bonjorn <%= @resource.email %> ! -Qualqu’un a demandat la reĩnicializacion de vòstre senhal per Mastodon. Podètz realizar la reĩnicializacion en clicant sul ligam çai-jos.</p> +Qualqu’un a demandat la reïnicializacion de vòstre senhal per Mastodon. Podètz realizar la reïnicializacion en clicant sul ligam çai-jos.</p> <%= link_to 'Modificar mon senhal', edit_password_url(@resource, reset_password_token: @token) %> diff --git a/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb b/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb index 940438b7c..9b21aae92 100644 --- a/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb +++ b/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb @@ -1,8 +1,8 @@ <p>Olá, <%= @resource.email %>!</p> -<p>Alguém solicitou um link para mudar a sua senha no <%= @instance %>. Você pode fazer isso através do link abaixo:</p> +<p>Alguém solicitou um link para mudar a sua senha na instância <%= @instance %>. Você pode fazer isso através do link abaixo:</p> <p><%= link_to 'Mudar a minha senha', edit_password_url(@resource, reset_password_token: @token) %></p> <p>Se você não solicitou isso, por favor ignore este e-mail.</p> -<p>A senha senha não será modificada até que você acesse o link acima e crie uma nova.</p> +<p>A senha não será modificada até que você acesse o link acima e crie uma nova.</p> diff --git a/app/views/user_mailer/reset_password_instructions.pt-BR.text.erb b/app/views/user_mailer/reset_password_instructions.pt-BR.text.erb index f574fe08f..2abff0c0d 100644 --- a/app/views/user_mailer/reset_password_instructions.pt-BR.text.erb +++ b/app/views/user_mailer/reset_password_instructions.pt-BR.text.erb @@ -1,8 +1,8 @@ Olá, <%= @resource.email %>! -Alguém solicitou um link para mudar a sua senha no <%= @instance %>. Você pode fazer isso através do link abaixo: +Alguém solicitou um link para mudar a sua senha na instância <%= @instance %>. Você pode fazer isso através do link abaixo: <%= edit_password_url(@resource, reset_password_token: @token) %> Se você não solicitou isso, por favor ignore este e-mail. -A senha senha não será modificada até que você acesse o link acima e crie uma nova. +A senha não será modificada até que você acesse o link acima e crie uma nova. diff --git a/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb b/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb index 51e3073f1..124305675 100644 --- a/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb +++ b/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb @@ -1,7 +1,8 @@ -<p><%= @resource.email %> ,嗨呀!!</p> +<p><%= @resource.email %>,你好呀!</p> -<p>有人(但愿是你)请求更改你Mastodon帐户的密码。如果是你的话,请点击下面的链接:</p> +<p>有人想修改你在 <%= @instance %> 上的密码呢。如果你确实想修改密码的话,点击下面的链接吧:</p> -<p><%= link_to '更改密码', edit_password_url(@resource, reset_password_token: @token) %></p> +<p><%= link_to '修改密码', edit_password_url(@resource, reset_password_token: @token) %></p> -<p>如果不是的话,忘了它吧。只有你本人通过上面的链接设置新的密码以后你的新密码才会生效。</p> +<p>如果你不想修改密码的话,还请忽略这封邮件哦。</p> +<p>在你点击上面的链接并修改密码前,你的密码是不会改变的。</p> diff --git a/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb b/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb index 7df590f78..f7cd88847 100644 --- a/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb +++ b/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb @@ -1,7 +1,8 @@ -<%= @resource.email %> ,嗨呀!! +<%= @resource.email %>,你好呀! -有人(但愿是你)请求更改你Mastodon帐户的密码。如果是你的话,请点击下面的链接: +有人想修改你在 <%= @instance %> 上的密码呢。如果你确实想修改密码的话,点击下面的链接吧: -<%= link_to '更改密码', edit_password_url(@resource, reset_password_token: @token) %> +<%= edit_password_url(@resource, reset_password_token: @token) %> -如果不是的话,忘了它吧。只有你本人通过上面的链接设置新的密码以后你的新密码才会生效。 +如果你不想修改密码的话,还请忽略这封邮件哦。 +在你点击上面的链接并修改密码前,你的密码是不会改变的。 diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb index 6338b1130..e41465ccc 100644 --- a/app/workers/admin/suspension_worker.rb +++ b/app/workers/admin/suspension_worker.rb @@ -6,6 +6,6 @@ class Admin::SuspensionWorker sidekiq_options queue: 'pull' def perform(account_id, remove_user = false) - SuspendAccountService.new.call(Account.find(account_id), remove_user) + SuspendAccountService.new.call(Account.find(account_id), remove_user: remove_user) end end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 65c02d3ef..1ae3c877b 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -3,34 +3,41 @@ class FeedInsertWorker include Sidekiq::Worker - attr_reader :status, :follower - - def perform(status_id, follower_id) - @status = Status.find_by(id: status_id) - @follower = Account.find_by(id: follower_id) + def perform(status_id, id, type = :home) + @type = type.to_sym + @status = Status.find(status_id) + + case @type + when :home + @follower = Account.find(id) + when :list + @list = List.find(id) + @follower = @list.account + end check_and_insert + rescue ActiveRecord::RecordNotFound + true end private def check_and_insert - if records_available? - perform_push unless feed_filtered? - else - true - end - end - - def records_available? - status.present? && follower.present? + perform_push unless feed_filtered? end def feed_filtered? - FeedManager.instance.filter?(:home, status, follower.id) + # Note: Lists are a variation of home, so the filtering rules + # of home apply to both + FeedManager.instance.filter?(:home, @status, @follower.id) end def perform_push - FeedManager.instance.push(:home, follower, status) + case @type + when :home + FeedManager.instance.push_to_home(@follower, @status) + when :list + FeedManager.instance.push_to_list(@list, @status) + end end end diff --git a/app/workers/push_update_worker.rb b/app/workers/push_update_worker.rb index 697cbd6a6..d76d73d96 100644 --- a/app/workers/push_update_worker.rb +++ b/app/workers/push_update_worker.rb @@ -3,12 +3,13 @@ class PushUpdateWorker include Sidekiq::Worker - def perform(account_id, status_id) - account = Account.find(account_id) - status = Status.find(status_id) - message = InlineRenderer.render(status, account, :status) + def perform(account_id, status_id, timeline_id = nil) + account = Account.find(account_id) + status = Status.find(status_id) + message = InlineRenderer.render(status, account, :status) + timeline_id = "timeline:#{account.id}" if timeline_id.nil? - Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :update, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) + Redis.current.publish(timeline_id, Oj.dump(event: :update, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) rescue ActiveRecord::RecordNotFound true end diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 38287e8e6..c18a778d5 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -3,7 +3,11 @@ class ThreadResolveWorker include Sidekiq::Worker - sidekiq_options queue: 'pull', retry: false + sidekiq_options queue: 'pull', retry: 3 + + sidekiq_retry_in do |count| + 15 + 10 * (count**4) + rand(10 * (count**4)) + end def perform(child_status_id, parent_url) child_status = Status.find(child_status_id) |