diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index a71bb61298..4b5afbe157 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -7,7 +7,6 @@ module Admin layout 'admin' - before_action :set_pack before_action :set_body_classes before_action :set_cache_headers @@ -19,10 +18,6 @@ module Admin @body_classes = 'admin' end - def set_pack - use_pack 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c76110dba7..bd152dbb66 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -19,6 +19,7 @@ class ApplicationController < ActionController::Base helper_method :current_session helper_method :current_flavour helper_method :current_skin + helper_method :current_theme helper_method :single_user_mode? helper_method :use_seamless_external_login? helper_method :omniauth_only? @@ -164,10 +165,7 @@ class ApplicationController < ActionController::Base def respond_with_error(code) respond_to do |format| - format.any do - use_pack 'error' - render "errors/#{code}", layout: 'error', status: code, formats: [:html] - end + format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } end end @@ -176,10 +174,7 @@ class ApplicationController < ActionController::Base return unless self_destruct? respond_to do |format| - format.any do - use_pack 'error' - render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] - end + format.any { render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] } format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 } end end diff --git a/app/controllers/auth/challenges_controller.rb b/app/controllers/auth/challenges_controller.rb index 1e585de189..7ede420b51 100644 --- a/app/controllers/auth/challenges_controller.rb +++ b/app/controllers/auth/challenges_controller.rb @@ -5,7 +5,6 @@ class Auth::ChallengesController < ApplicationController layout 'auth' - before_action :set_pack before_action :authenticate_user! skip_before_action :check_self_destruct! @@ -21,10 +20,4 @@ class Auth::ChallengesController < ApplicationController render_challenge end end - - private - - def set_pack - use_pack 'auth' - end end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 8b8a27d260..7ca7be5f8e 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -6,7 +6,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' before_action :set_body_classes - before_action :set_pack before_action :set_confirmation_user!, only: [:show, :confirm_captcha] before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? @@ -66,10 +65,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController @confirmation_user.nil? || @confirmation_user.confirmed? end - def set_pack - use_pack 'auth' - end - def redirect_confirmed_user redirect_to(current_user.approved? ? root_path : edit_user_registration_path) end diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index f0d47bf774..de001f062b 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -3,7 +3,6 @@ class Auth::PasswordsController < Devise::PasswordsController skip_before_action :check_self_destruct! before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? - before_action :set_pack before_action :set_body_classes layout 'auth' @@ -32,8 +31,4 @@ class Auth::PasswordsController < Devise::PasswordsController def reset_password_token_is_valid? resource_class.with_reset_password_token(params[:reset_password_token]).present? end - - def set_pack - use_pack 'auth' - end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 838869b2ee..acfc0af0d9 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -9,7 +9,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_invite, only: [:new, :create] before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] - before_action :set_pack before_action :set_sessions, only: [:edit, :update] before_action :set_strikes, only: [:edit, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update] @@ -97,10 +96,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController private - def set_pack - use_pack %w(edit update).include?(action_name) ? 'admin' : 'auth' - end - def set_body_classes @body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter' end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 67d369b859..6ed7b2baac 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -12,7 +12,6 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_functional! skip_before_action :update_user_sign_in - prepend_before_action :set_pack prepend_before_action :check_suspicious!, only: [:create] include Auth::TwoFactorAuthenticationConcern @@ -104,10 +103,6 @@ class Auth::SessionsController < Devise::SessionsController private - def set_pack - use_pack 'auth' - end - def set_body_classes @body_classes = 'lighter' end diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb index 8edca4d01b..40916d2887 100644 --- a/app/controllers/auth/setup_controller.rb +++ b/app/controllers/auth/setup_controller.rb @@ -3,7 +3,6 @@ class Auth::SetupController < ApplicationController layout 'auth' - before_action :set_pack before_action :authenticate_user! before_action :require_unconfirmed_or_pending! before_action :set_body_classes @@ -43,8 +42,4 @@ class Auth::SetupController < ApplicationController def user_params params.require(:user).permit(:email) end - - def set_pack - use_pack 'sign_up' - end end diff --git a/app/controllers/concerns/auth/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb index edcdd2990f..404164751a 100644 --- a/app/controllers/concerns/auth/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/auth/two_factor_authentication_concern.rb @@ -83,8 +83,6 @@ module Auth::TwoFactorAuthenticationConcern def prompt_for_two_factor(user) register_attempt_in_session(user) - use_pack 'auth' - @body_classes = 'lighter' @webauthn_enabled = user.webauthn_enabled? @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? diff --git a/app/controllers/concerns/theming_concern.rb b/app/controllers/concerns/theming_concern.rb index 82a53dbf51..ebdaf54c05 100644 --- a/app/controllers/concerns/theming_concern.rb +++ b/app/controllers/concerns/theming_concern.rb @@ -3,87 +3,22 @@ module ThemingConcern extend ActiveSupport::Concern - def use_pack(pack_name) - @core = resolve_pack_with_common(Themes.instance.core, pack_name) - @theme = resolve_pack_with_common(Themes.instance.flavour(current_flavour), pack_name, current_skin) - end - private def current_flavour - [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) } + @current_flavour ||= [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) } end def current_skin - skins = Themes.instance.skins_for(current_flavour) - [current_user&.setting_skin, Setting.skin, 'default'].find { |skin| skins.include?(skin) } - end - - def valid_pack_data?(data, pack_name) - data['pack'].is_a?(Hash) && data['pack'][pack_name].present? - end - - def nil_pack(data) - { - use_common: true, - flavour: data['name'], - pack: nil, - preload: nil, - skin: nil, - supported_locales: data['locales'], - } - end - - def pack(data, pack_name, skin) - pack_data = { - use_common: true, - flavour: data['name'], - pack: pack_name, - preload: nil, - skin: nil, - supported_locales: data['locales'], - } - - return pack_data unless data['pack'][pack_name].is_a?(Hash) - - pack_data[:use_common] = false if data['pack'][pack_name]['use_common'] == false - pack_data[:pack] = nil unless data['pack'][pack_name]['filename'] - - preloads = data['pack'][pack_name]['preload'] - pack_data[:preload] = [preloads] if preloads.is_a?(String) - pack_data[:preload] = preloads if preloads.is_a?(Array) - - if skin != 'default' && data['skin'][skin] - pack_data[:skin] = skin if data['skin'][skin].include?(pack_name) - elsif data['pack'][pack_name]['stylesheet'] - pack_data[:skin] = 'default' + @current_skin ||= begin + skins = Themes.instance.skins_for(current_flavour) + [current_user&.setting_skin, Setting.skin, 'default'].find { |skin| skins.include?(skin) } end - - pack_data end - def resolve_pack(data, pack_name, skin) - return pack(data, pack_name, skin) if valid_pack_data?(data, pack_name) - return if data['name'].blank? - - fallbacks = [] - if data.key?('fallback') - fallbacks = data['fallback'] if data['fallback'].is_a?(Array) - fallbacks = [data['fallback']] if data['fallback'].is_a?(String) - elsif data['name'] != Setting.default_settings['flavour'] - fallbacks = [Setting.default_settings['flavour']] - end - - fallbacks.each do |fallback| - return resolve_pack(Themes.instance.flavour(fallback), pack_name, skin) if Themes.instance.flavour(fallback) - end - - nil - end - - def resolve_pack_with_common(data, pack_name, skin = 'default') - result = resolve_pack(data, pack_name, skin) || nil_pack(data) - result[:common] = resolve_pack(data, 'common', skin) if result.delete(:use_common) - result + def current_theme + # NOTE: this is slightly different from upstream, as it's a derived value used + # for the sole purpose of pointing to the appropriate stylesheet pack + "skins/#{current_flavour}/#{current_skin}" end end diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index a6af62af97..24cccf1667 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -7,7 +7,6 @@ module WebAppControllerConcern vary_by 'Accept, Accept-Language, Cookie' before_action :redirect_unauthenticated_to_permalinks! - before_action :set_pack before_action :set_app_body_class end @@ -37,8 +36,4 @@ module WebAppControllerConcern end end end - - def set_pack - use_pack 'home' - end end diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb index f51f44c620..1054f3db80 100644 --- a/app/controllers/disputes/base_controller.rb +++ b/app/controllers/disputes/base_controller.rb @@ -9,15 +9,10 @@ class Disputes::BaseController < ApplicationController before_action :set_body_classes before_action :authenticate_user! - before_action :set_pack before_action :set_cache_headers private - def set_pack - use_pack 'admin' - end - def set_body_classes @body_classes = 'admin' end diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb index 97206c7eda..94993f938b 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -6,7 +6,6 @@ class Filters::StatusesController < ApplicationController before_action :authenticate_user! before_action :set_filter before_action :set_status_filters - before_action :set_pack before_action :set_body_classes before_action :set_cache_headers @@ -27,10 +26,6 @@ class Filters::StatusesController < ApplicationController private - def set_pack - use_pack 'admin' - end - def set_filter @filter = current_account.custom_filters.find(params[:filter_id]) end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 1b19269b23..bd9964426b 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -5,7 +5,6 @@ class FiltersController < ApplicationController before_action :authenticate_user! before_action :set_filter, only: [:edit, :update, :destroy] - before_action :set_pack before_action :set_body_classes before_action :set_cache_headers @@ -45,10 +44,6 @@ class FiltersController < ApplicationController private - def set_pack - use_pack 'settings' - end - def set_filter @filter = current_account.custom_filters.find(params[:id]) end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 2db4bc5cbd..9bc5164d59 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,7 +6,6 @@ class InvitesController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_pack before_action :set_body_classes before_action :set_cache_headers @@ -40,10 +39,6 @@ class InvitesController < ApplicationController private - def set_pack - use_pack 'settings' - end - def invites current_user.invites.order(id: :desc) end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 4c028dbef0..53eee40012 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -10,7 +10,6 @@ class MediaController < ApplicationController before_action :verify_permitted_status! before_action :check_playable, only: :player before_action :allow_iframing, only: :player - before_action :set_pack, only: :player content_security_policy only: :player do |policy| policy.frame_ancestors(false) @@ -48,8 +47,4 @@ class MediaController < ApplicationController def allow_iframing response.headers.delete('X-Frame-Options') end - - def set_pack - use_pack 'public' - end end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 62fc9c1b0d..66e774425d 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -5,7 +5,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController before_action :store_current_location before_action :authenticate_resource_owner! - before_action :set_pack before_action :set_cache_headers content_security_policy do |p| @@ -20,10 +19,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController store_location_for(:user, request.url) end - def set_pack - use_pack 'auth' - end - def render_success if skip_authorization? || (matching_token? && !truthy_param?('force_login')) redirect_or_render authorize_response diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 778912c948..8440df6b7e 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -5,7 +5,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :store_current_location before_action :authenticate_resource_owner! - before_action :set_pack before_action :require_not_suspended!, only: :destroy before_action :set_body_classes before_action :set_cache_headers @@ -31,10 +30,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio store_location_for(:user, request.url) end - def set_pack - use_pack 'settings' - end - def require_not_suspended! forbidden if current_account.unavailable? end diff --git a/app/controllers/redirect/base_controller.rb b/app/controllers/redirect/base_controller.rb index ce55cfc53a..90894ec1ed 100644 --- a/app/controllers/redirect/base_controller.rb +++ b/app/controllers/redirect/base_controller.rb @@ -3,7 +3,6 @@ class Redirect::BaseController < ApplicationController vary_by 'Accept-Language' - before_action :set_pack before_action :set_resource before_action :set_app_body_class @@ -22,8 +21,4 @@ class Redirect::BaseController < ApplicationController def set_resource raise NotImplementedError end - - def set_pack - use_pack 'public' - end end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index e6e7c24752..dd794f3199 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -5,7 +5,6 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show - before_action :set_pack before_action :set_relationships, only: :show before_action :set_body_classes before_action :set_cache_headers @@ -73,10 +72,6 @@ class RelationshipsController < ApplicationController @body_classes = 'admin' end - def set_pack - use_pack 'admin' - end - def set_cache_headers response.cache_control.replace(private: true, no_store: true) end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 4d3d6e6a1b..f15140aa2b 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Settings::BaseController < ApplicationController - before_action :set_pack layout 'admin' before_action :authenticate_user! @@ -10,10 +9,6 @@ class Settings::BaseController < ApplicationController private - def set_pack - use_pack 'settings' - end - def set_body_classes @body_classes = 'admin' end diff --git a/app/controllers/settings/login_activities_controller.rb b/app/controllers/settings/login_activities_controller.rb index 018fc7e950..50e2d70cb9 100644 --- a/app/controllers/settings/login_activities_controller.rb +++ b/app/controllers/settings/login_activities_controller.rb @@ -7,10 +7,4 @@ class Settings::LoginActivitiesController < Settings::BaseController def index @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page]) end - - private - - def set_pack - use_pack 'settings' - end end diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb index aed5af6e79..9714d54f95 100644 --- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -85,10 +85,6 @@ module Settings private - def set_pack - use_pack 'auth' - end - def redirect_invalid_otp flash[:error] = t('webauthn_credentials.otp_required') redirect_to settings_two_factor_authentication_methods_path diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index e13e7e8b65..6546b84978 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -4,17 +4,12 @@ class SharesController < ApplicationController layout 'modal' before_action :authenticate_user! - before_action :set_pack before_action :set_body_classes def show; end private - def set_pack - use_pack 'share' - end - def set_body_classes @body_classes = 'modal-layout compose-standalone' end diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb index 88cf26d74d..4a3fc10ca4 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -6,7 +6,6 @@ class StatusesCleanupController < ApplicationController before_action :authenticate_user! before_action :set_policy before_action :set_body_classes - before_action :set_pack before_action :set_cache_headers def show; end @@ -27,10 +26,6 @@ class StatusesCleanupController < ApplicationController private - def set_pack - use_pack 'settings' - end - def set_policy @policy = current_account.statuses_cleanup_policy || current_account.build_statuses_cleanup_policy(enabled: false) end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 02fea13502..db7eddd78b 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -41,7 +41,6 @@ class StatusesController < ApplicationController end def embed - use_pack 'embed' return not_found if @status.hidden? || @status.reblog? expires_in 180, public: true diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 233c242e4a..b0114132cc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -233,6 +233,25 @@ module ApplicationHelper EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s end + # glitch-soc addition to handle the multiple flavors + def preload_locale_pack + supported_locales = Themes.instance.flavour(current_flavour)['locales'] + preload_pack_asset "locales/#{current_flavour}/#{I18n.locale}-json.js" if supported_locales.include?(I18n.locale.to_s) + end + + def flavoured_javascript_pack_tag(pack_name, **options) + javascript_pack_tag("flavours/#{current_flavour}/#{pack_name}", **options) + end + + def flavoured_stylesheet_pack_tag(pack_name, **options) + stylesheet_pack_tag("flavours/#{current_flavour}/#{pack_name}", **options) + end + + def preload_signed_in_js_packs + preload_files = Themes.instance.flavour(current_flavour)&.fetch('signed_in_preload', nil) || [] + safe_join(preload_files.map { |entry| preload_pack_asset entry }) + end + private def storage_host_var diff --git a/app/javascript/core/admin.ts b/app/javascript/core/admin.ts deleted file mode 100644 index 0642affef0..0000000000 --- a/app/javascript/core/admin.ts +++ /dev/null @@ -1,340 +0,0 @@ -// This file will be loaded on admin pages, regardless of theme. - -import 'packs/public-path'; - -import Rails from '@rails/ujs'; - -import ready from '../mastodon/ready'; - -const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { - const valid = target.value && target.validity.valid; - const element = document.querySelector( - 'input[type="datetime-local"]#announcement_ends_at', - ); - - if (!element) return; - - if (valid) { - element.classList.remove('optional'); - element.required = true; - element.min = target.value; - } else { - element.classList.add('optional'); - element.removeAttribute('required'); - element.removeAttribute('min'); - } -}; - -Rails.delegate( - document, - 'input[type="datetime-local"]#announcement_starts_at', - 'change', - ({ target }) => { - if (target instanceof HTMLInputElement) - setAnnouncementEndsAttributes(target); - }, -); - -const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; - -const showSelectAll = () => { - const selectAllMatchingElement = document.querySelector( - '.batch-table__select-all', - ); - selectAllMatchingElement?.classList.add('active'); -}; - -const hideSelectAll = () => { - const selectAllMatchingElement = document.querySelector( - '.batch-table__select-all', - ); - const hiddenField = document.querySelector( - 'input#select_all_matching', - ); - const selectedMsg = document.querySelector( - '.batch-table__select-all .selected', - ); - const notSelectedMsg = document.querySelector( - '.batch-table__select-all .not-selected', - ); - - selectAllMatchingElement?.classList.remove('active'); - selectedMsg?.classList.remove('active'); - notSelectedMsg?.classList.add('active'); - if (hiddenField) hiddenField.value = '0'; -}; - -Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { - if (!(target instanceof HTMLInputElement)) return; - - const selectAllMatchingElement = document.querySelector( - '.batch-table__select-all', - ); - - document - .querySelectorAll(batchCheckboxClassName) - .forEach((content) => { - content.checked = target.checked; - }); - - if (selectAllMatchingElement) { - if (target.checked) { - showSelectAll(); - } else { - hideSelectAll(); - } - } -}); - -Rails.delegate(document, '.batch-table__select-all button', 'click', () => { - const hiddenField = document.querySelector( - '#select_all_matching', - ); - - if (!hiddenField) return; - - const active = hiddenField.value === '1'; - const selectedMsg = document.querySelector( - '.batch-table__select-all .selected', - ); - const notSelectedMsg = document.querySelector( - '.batch-table__select-all .not-selected', - ); - - if (!selectedMsg || !notSelectedMsg) return; - - if (active) { - hiddenField.value = '0'; - selectedMsg.classList.remove('active'); - notSelectedMsg.classList.add('active'); - } else { - hiddenField.value = '1'; - notSelectedMsg.classList.remove('active'); - selectedMsg.classList.add('active'); - } -}); - -Rails.delegate(document, batchCheckboxClassName, 'change', () => { - const checkAllElement = document.querySelector( - 'input#batch_checkbox_all', - ); - const selectAllMatchingElement = document.querySelector( - '.batch-table__select-all', - ); - - if (checkAllElement) { - const allCheckboxes = Array.from( - document.querySelectorAll(batchCheckboxClassName), - ); - checkAllElement.checked = allCheckboxes.every((content) => content.checked); - checkAllElement.indeterminate = - !checkAllElement.checked && - allCheckboxes.some((content) => content.checked); - - if (selectAllMatchingElement) { - if (checkAllElement.checked) { - showSelectAll(); - } else { - hideSelectAll(); - } - } - } -}); - -Rails.delegate( - document, - '.filter-subset--with-select select', - 'change', - ({ target }) => { - if (target instanceof HTMLSelectElement) target.form?.submit(); - }, -); - -const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { - const rejectMediaDiv = document.querySelector( - '.input.with_label.domain_block_reject_media', - ); - const rejectReportsDiv = document.querySelector( - '.input.with_label.domain_block_reject_reports', - ); - - if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) { - rejectMediaDiv.style.display = - target.value === 'suspend' ? 'none' : 'block'; - } - - if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) { - rejectReportsDiv.style.display = - target.value === 'suspend' ? 'none' : 'block'; - } -}; - -Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { - if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); -}); - -const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { - const bootstrapTimelineAccountsField = - document.querySelector( - '#form_admin_settings_bootstrap_timeline_accounts', - ); - - if (bootstrapTimelineAccountsField) { - bootstrapTimelineAccountsField.disabled = !target.checked; - if (target.checked) { - bootstrapTimelineAccountsField.parentElement?.classList.remove( - 'disabled', - ); - bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove( - 'disabled', - ); - } else { - bootstrapTimelineAccountsField.parentElement?.classList.add('disabled'); - bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add( - 'disabled', - ); - } - } -}; - -Rails.delegate( - document, - '#form_admin_settings_enable_bootstrap_timeline_accounts', - 'change', - ({ target }) => { - if (target instanceof HTMLInputElement) - onEnableBootstrapTimelineAccountsChange(target); - }, -); - -const onChangeRegistrationMode = (target: HTMLSelectElement) => { - const enabled = target.value === 'approved'; - - document - .querySelectorAll( - '.form_admin_settings_registrations_mode .warning-hint', - ) - .forEach((warning_hint) => { - warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; - }); - - document - .querySelectorAll( - 'input#form_admin_settings_require_invite_text', - ) - .forEach((input) => { - input.disabled = !enabled; - if (enabled) { - let element: HTMLElement | null = input; - do { - element.classList.remove('disabled'); - element = element.parentElement; - } while (element && !element.classList.contains('fields-group')); - } else { - let element: HTMLElement | null = input; - do { - element.classList.add('disabled'); - element = element.parentElement; - } while (element && !element.classList.contains('fields-group')); - } - }); -}; - -const convertUTCDateTimeToLocal = (value: string) => { - const date = new Date(value + 'Z'); - const twoChars = (x: number) => x.toString().padStart(2, '0'); - return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; -}; - -function convertLocalDatetimeToUTC(value: string) { - const date = new Date(value); - const fullISO8601 = date.toISOString(); - return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); -} - -Rails.delegate( - document, - '#form_admin_settings_registrations_mode', - 'change', - ({ target }) => { - if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); - }, -); - -ready(() => { - const domainBlockSeveritySelect = document.querySelector( - 'select#domain_block_severity', - ); - if (domainBlockSeveritySelect) - onDomainBlockSeverityChange(domainBlockSeveritySelect); - - const enableBootstrapTimelineAccounts = - document.querySelector( - 'input#form_admin_settings_enable_bootstrap_timeline_accounts', - ); - if (enableBootstrapTimelineAccounts) - onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); - - const registrationMode = document.querySelector( - 'select#form_admin_settings_registrations_mode', - ); - if (registrationMode) onChangeRegistrationMode(registrationMode); - - const checkAllElement = document.querySelector( - 'input#batch_checkbox_all', - ); - if (checkAllElement) { - const allCheckboxes = Array.from( - document.querySelectorAll(batchCheckboxClassName), - ); - checkAllElement.checked = allCheckboxes.every((content) => content.checked); - checkAllElement.indeterminate = - !checkAllElement.checked && - allCheckboxes.some((content) => content.checked); - } - - document - .querySelector('a#add-instance-button') - ?.addEventListener('click', (e) => { - const domain = document.querySelector( - 'input[type="text"]#by_domain', - )?.value; - - if (domain && e.target instanceof HTMLAnchorElement) { - const url = new URL(e.target.href); - url.searchParams.set('_domain', domain); - e.target.href = url.toString(); - } - }); - - document - .querySelectorAll('input[type="datetime-local"]') - .forEach((element) => { - if (element.value) { - element.value = convertUTCDateTimeToLocal(element.value); - } - if (element.placeholder) { - element.placeholder = convertUTCDateTimeToLocal(element.placeholder); - } - }); - - Rails.delegate(document, 'form', 'submit', ({ target }) => { - if (target instanceof HTMLFormElement) - target - .querySelectorAll('input[type="datetime-local"]') - .forEach((element) => { - if (element.value && element.validity.valid) { - element.value = convertLocalDatetimeToUTC(element.value); - } - }); - }); - - const announcementStartsAt = document.querySelector( - 'input[type="datetime-local"]#announcement_starts_at', - ); - if (announcementStartsAt) { - setAnnouncementEndsAttributes(announcementStartsAt); - } -}).catch((reason) => { - throw reason; -}); diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js deleted file mode 100644 index d1d14d99e8..0000000000 --- a/app/javascript/core/auth.js +++ /dev/null @@ -1,3 +0,0 @@ -import 'packs/public-path'; -import './settings'; -import './two_factor_authentication'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js deleted file mode 100644 index 1cee2f6036..0000000000 --- a/app/javascript/core/common.js +++ /dev/null @@ -1,6 +0,0 @@ -// This file will be loaded on all pages, regardless of theme. - -import 'packs/public-path'; -import 'font-awesome/css/font-awesome.css'; - -require.context('../images/', true); diff --git a/app/javascript/core/embed.ts b/app/javascript/core/embed.ts deleted file mode 100644 index 6766cd7788..0000000000 --- a/app/javascript/core/embed.ts +++ /dev/null @@ -1,41 +0,0 @@ -// This file will be loaded on embed pages, regardless of theme. - -import 'packs/public-path'; -import ready from '../mastodon/ready'; - -interface SetHeightMessage { - type: 'setHeight'; - id: string; - height: number; -} - -function isSetHeightMessage(data: unknown): data is SetHeightMessage { - if ( - data && - typeof data === 'object' && - 'type' in data && - data.type === 'setHeight' - ) - return true; - else return false; -} - -window.addEventListener('message', (e) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases - if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; - - const data = e.data; - - ready(() => { - window.parent.postMessage( - { - type: 'setHeight', - id: data.id, - height: document.getElementsByTagName('html')[0].scrollHeight, - }, - '*', - ); - }).catch((e) => { - console.error('Error in setHeightMessage postMessage', e); - }); -}); diff --git a/app/javascript/core/settings.ts b/app/javascript/core/settings.ts deleted file mode 100644 index ea6a99ec80..0000000000 --- a/app/javascript/core/settings.ts +++ /dev/null @@ -1,70 +0,0 @@ -// This file will be loaded on settings pages, regardless of theme. - -import 'packs/public-path'; -import Rails from '@rails/ujs'; - -Rails.delegate( - document, - '#edit_profile input[type=file]', - 'change', - ({ target }) => { - if (!(target instanceof HTMLInputElement)) return; - - const avatar = document.querySelector( - `img#${target.id}-preview`, - ); - - if (!avatar) return; - - let file: File | undefined; - if (target.files) file = target.files[0]; - - const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; - - if (url) avatar.src = url; - }, -); - -Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { - if (!(target instanceof HTMLInputElement)) return; - - target.focus(); - target.select(); - target.setSelectionRange(0, target.value.length); -}); - -Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { - if (!(target instanceof HTMLButtonElement)) return; - - const input = target.parentNode?.querySelector( - '.input-copy__wrapper input', - ); - - if (!input) return; - - const oldReadOnly = input.readOnly; - - input.readOnly = false; - input.focus(); - input.select(); - input.setSelectionRange(0, input.value.length); - - try { - if (document.execCommand('copy')) { - input.blur(); - - const parent = target.parentElement; - - if (!parent) return; - parent.classList.add('copied'); - - setTimeout(() => { - parent.classList.remove('copied'); - }, 700); - } - } catch (err) { - console.error(err); - } - - input.readOnly = oldReadOnly; -}); diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml deleted file mode 100644 index 12c23e2035..0000000000 --- a/app/javascript/core/theme.yml +++ /dev/null @@ -1,24 +0,0 @@ -# These packs will be loaded on every appropriate page, regardless of -# theme. -pack: - about: - admin: admin.ts - auth: auth.js - common: - filename: common.js - stylesheet: true - embed: embed.ts - error: - home: - inert: - filename: inert.js - stylesheet: true - mailer: - filename: mailer.js - stylesheet: true - modal: - public: - settings: settings.ts - sign_up: - share: - remote_interaction_helper: remote_interaction_helper.ts diff --git a/app/javascript/flavours/glitch/common.js b/app/javascript/flavours/glitch/common.js new file mode 100644 index 0000000000..40a324a59b --- /dev/null +++ b/app/javascript/flavours/glitch/common.js @@ -0,0 +1,12 @@ +import Rails from '@rails/ujs'; +import 'font-awesome/css/font-awesome.css'; + +export function start() { + require.context('@/images/', true); + + try { + Rails.start(); + } catch (e) { + // If called twice + } +} diff --git a/app/javascript/flavours/glitch/packs/admin.tsx b/app/javascript/flavours/glitch/packs/admin.tsx index e20e74b238..57a017ff7d 100644 --- a/app/javascript/flavours/glitch/packs/admin.tsx +++ b/app/javascript/flavours/glitch/packs/admin.tsx @@ -1,8 +1,265 @@ import 'packs/public-path'; import { createRoot } from 'react-dom/client'; +import Rails from '@rails/ujs'; + import ready from 'flavours/glitch/ready'; +const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { + const valid = target.value && target.validity.valid; + const element = document.querySelector( + 'input[type="datetime-local"]#announcement_ends_at', + ); + + if (!element) return; + + if (valid) { + element.classList.remove('optional'); + element.required = true; + element.min = target.value; + } else { + element.classList.add('optional'); + element.removeAttribute('required'); + element.removeAttribute('min'); + } +}; + +Rails.delegate( + document, + 'input[type="datetime-local"]#announcement_starts_at', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + setAnnouncementEndsAttributes(target); + }, +); + +const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; + +const showSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + selectAllMatchingElement?.classList.add('active'); +}; + +const hideSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + const hiddenField = document.querySelector( + 'input#select_all_matching', + ); + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + selectAllMatchingElement?.classList.remove('active'); + selectedMsg?.classList.remove('active'); + notSelectedMsg?.classList.add('active'); + if (hiddenField) hiddenField.value = '0'; +}; + +Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + document + .querySelectorAll(batchCheckboxClassName) + .forEach((content) => { + content.checked = target.checked; + }); + + if (selectAllMatchingElement) { + if (target.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } +}); + +Rails.delegate(document, '.batch-table__select-all button', 'click', () => { + const hiddenField = document.querySelector( + '#select_all_matching', + ); + + if (!hiddenField) return; + + const active = hiddenField.value === '1'; + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + if (!selectedMsg || !notSelectedMsg) return; + + if (active) { + hiddenField.value = '0'; + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + } else { + hiddenField.value = '1'; + notSelectedMsg.classList.remove('active'); + selectedMsg.classList.add('active'); + } +}); + +Rails.delegate(document, batchCheckboxClassName, 'change', () => { + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + + if (selectAllMatchingElement) { + if (checkAllElement.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } + } +}); + +Rails.delegate( + document, + '.filter-subset--with-select select', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) target.form?.submit(); + }, +); + +const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { + const rejectMediaDiv = document.querySelector( + '.input.with_label.domain_block_reject_media', + ); + const rejectReportsDiv = document.querySelector( + '.input.with_label.domain_block_reject_reports', + ); + + if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) { + rejectMediaDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } + + if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) { + rejectReportsDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } +}; + +Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { + if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); +}); + +const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { + const bootstrapTimelineAccountsField = + document.querySelector( + '#form_admin_settings_bootstrap_timeline_accounts', + ); + + if (bootstrapTimelineAccountsField) { + bootstrapTimelineAccountsField.disabled = !target.checked; + if (target.checked) { + bootstrapTimelineAccountsField.parentElement?.classList.remove( + 'disabled', + ); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + bootstrapTimelineAccountsField.parentElement?.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +Rails.delegate( + document, + '#form_admin_settings_enable_bootstrap_timeline_accounts', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + onEnableBootstrapTimelineAccountsChange(target); + }, +); + +const onChangeRegistrationMode = (target: HTMLSelectElement) => { + const enabled = target.value === 'approved'; + + document + .querySelectorAll( + '.form_admin_settings_registrations_mode .warning-hint', + ) + .forEach((warning_hint) => { + warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; + }); + + document + .querySelectorAll( + 'input#form_admin_settings_require_invite_text', + ) + .forEach((input) => { + input.disabled = !enabled; + if (enabled) { + let element: HTMLElement | null = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element: HTMLElement | null = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }); +}; + +const convertUTCDateTimeToLocal = (value: string) => { + const date = new Date(value + 'Z'); + const twoChars = (x: number) => x.toString().padStart(2, '0'); + return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; +}; + +function convertLocalDatetimeToUTC(value: string) { + const date = new Date(value); + const fullISO8601 = date.toISOString(); + return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); +} + +Rails.delegate( + document, + '#form_admin_settings_registrations_mode', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); + }, +); + async function mountReactComponent(element: Element) { const componentName = element.getAttribute('data-admin-component'); const stringProps = element.getAttribute('data-props'); @@ -29,6 +286,80 @@ async function mountReactComponent(element: Element) { } ready(() => { + const domainBlockSeveritySelect = document.querySelector( + 'select#domain_block_severity', + ); + if (domainBlockSeveritySelect) + onDomainBlockSeverityChange(domainBlockSeveritySelect); + + const enableBootstrapTimelineAccounts = + document.querySelector( + 'input#form_admin_settings_enable_bootstrap_timeline_accounts', + ); + if (enableBootstrapTimelineAccounts) + onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.querySelector( + 'select#form_admin_settings_registrations_mode', + ); + if (registrationMode) onChangeRegistrationMode(registrationMode); + + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + } + + document + .querySelector('a#add-instance-button') + ?.addEventListener('click', (e) => { + const domain = document.querySelector( + 'input[type="text"]#by_domain', + )?.value; + + if (domain && e.target instanceof HTMLAnchorElement) { + const url = new URL(e.target.href); + url.searchParams.set('_domain', domain); + e.target.href = url.toString(); + } + }); + + document + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value) { + element.value = convertUTCDateTimeToLocal(element.value); + } + if (element.placeholder) { + element.placeholder = convertUTCDateTimeToLocal(element.placeholder); + } + }); + + Rails.delegate(document, 'form', 'submit', ({ target }) => { + if (target instanceof HTMLFormElement) + target + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value && element.validity.valid) { + element.value = convertLocalDatetimeToUTC(element.value); + } + }); + }); + + const announcementStartsAt = document.querySelector( + 'input[type="datetime-local"]#announcement_starts_at', + ); + if (announcementStartsAt) { + setAnnouncementEndsAttributes(announcementStartsAt); + } + document.querySelectorAll('[data-admin-component]').forEach((element) => { void mountReactComponent(element); }); diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/application.js similarity index 82% rename from app/javascript/flavours/glitch/packs/home.js rename to app/javascript/flavours/glitch/packs/application.js index 842430354d..336a250f54 100644 --- a/app/javascript/flavours/glitch/packs/home.js +++ b/app/javascript/flavours/glitch/packs/application.js @@ -1,8 +1,12 @@ import 'packs/public-path'; + +import { start } from 'flavours/glitch/common'; import { loadLocale } from 'flavours/glitch/locales'; import main from "flavours/glitch/main"; import { loadPolyfills } from 'flavours/glitch/polyfills'; +start(); + loadPolyfills() .then(loadLocale) .then(main) diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js index caad60a8c3..79467fc493 100644 --- a/app/javascript/flavours/glitch/packs/common.js +++ b/app/javascript/flavours/glitch/packs/common.js @@ -1,8 +1,8 @@ +/* This file is a hack to have something more reliable than the upstream `common` tag + that is implicitly generated as the common chunk through webpack's `splitChunks` config */ + import 'packs/public-path'; -import Rails from '@rails/ujs'; -import 'flavours/glitch/styles/index.scss'; +import 'font-awesome/css/font-awesome.css'; -Rails.start(); - -// This ensures that webpack compiles our images. +// This is a hack to ensures that webpack compiles our images. require.context('../images', true); diff --git a/app/javascript/flavours/glitch/packs/inert.js b/app/javascript/flavours/glitch/packs/inert.js new file mode 100644 index 0000000000..a5d7e548be --- /dev/null +++ b/app/javascript/flavours/glitch/packs/inert.js @@ -0,0 +1,4 @@ +/* Placeholder file to have `inert.scss` compiled by Webpack + This is used by the `wicg-inert` polyfill */ + +import '@/styles/inert.scss'; diff --git a/app/javascript/flavours/glitch/packs/mailer.js b/app/javascript/flavours/glitch/packs/mailer.js new file mode 100644 index 0000000000..28cbb906f5 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/mailer.js @@ -0,0 +1,3 @@ +import '@/styles/mailer.scss'; + +require.context('@/icons'); diff --git a/app/javascript/flavours/glitch/packs/public.tsx b/app/javascript/flavours/glitch/packs/public.tsx index 1ecc3fa42f..6087f7b7c1 100644 --- a/app/javascript/flavours/glitch/packs/public.tsx +++ b/app/javascript/flavours/glitch/packs/public.tsx @@ -10,6 +10,7 @@ import Rails from '@rails/ujs'; import axios from 'axios'; import { throttle } from 'lodash'; +import { start } from 'flavours/glitch/common'; import { timeAgoString } from 'flavours/glitch/components/relative_timestamp'; import emojify from 'flavours/glitch/features/emoji/emoji'; import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions'; @@ -19,6 +20,8 @@ import ready from 'flavours/glitch/ready'; import 'cocoon-js-vanilla'; +start(); + const messages = defineMessages({ usernameTaken: { id: 'username.taken', @@ -34,6 +37,43 @@ const messages = defineMessages({ }, }); +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + ready(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0].scrollHeight, + }, + '*', + ); + }).catch((e) => { + console.error('Error in setHeightMessage postMessage', e); + }); +}); + function loaded() { const { messages: localeData } = getLocale(); @@ -285,6 +325,72 @@ function loaded() { }); } +Rails.delegate( + document, + '#edit_profile input[type=file]', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const avatar = document.querySelector( + `img#${target.id}-preview`, + ); + + if (!avatar) return; + + let file: File | undefined; + if (target.files) file = target.files[0]; + + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + if (url) avatar.src = url; + }, +); + +Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { + if (!(target instanceof HTMLButtonElement)) return; + + const input = target.parentNode?.querySelector( + '.input-copy__wrapper input', + ); + + if (!input) return; + + const oldReadOnly = input.readOnly; + + input.readOnly = false; + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (document.execCommand('copy')) { + input.blur(); + + const parent = target.parentElement; + + if (!parent) return; + parent.classList.add('copied'); + + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + + input.readOnly = oldReadOnly; +}); + const toggleSidebar = () => { const sidebar = document.querySelector('.sidebar ul'); const toggleButton = document.querySelector( diff --git a/app/javascript/core/remote_interaction_helper.ts b/app/javascript/flavours/glitch/packs/remote_interaction_helper.ts similarity index 100% rename from app/javascript/core/remote_interaction_helper.ts rename to app/javascript/flavours/glitch/packs/remote_interaction_helper.ts diff --git a/app/javascript/flavours/glitch/packs/share.jsx b/app/javascript/flavours/glitch/packs/share.jsx index 7a4e333653..3d938f5b2a 100644 --- a/app/javascript/flavours/glitch/packs/share.jsx +++ b/app/javascript/flavours/glitch/packs/share.jsx @@ -1,10 +1,13 @@ import 'packs/public-path'; import { createRoot } from 'react-dom/client'; +import { start } from 'flavours/glitch/common'; import ComposeContainer from 'flavours/glitch/containers/compose_container'; import { loadPolyfills } from 'flavours/glitch/polyfills'; import ready from 'flavours/glitch/ready'; +start(); + function loaded() { const mountNode = document.getElementById('mastodon-compose'); diff --git a/app/javascript/flavours/glitch/packs/two_factor_authentication.js b/app/javascript/flavours/glitch/packs/two_factor_authentication.js new file mode 100644 index 0000000000..8b606fcc7a --- /dev/null +++ b/app/javascript/flavours/glitch/packs/two_factor_authentication.js @@ -0,0 +1,119 @@ +import * as WebAuthnJSON from '@github/webauthn-json'; +import axios from 'axios'; + +import ready from 'flavours/glitch/ready'; +import 'regenerator-runtime/runtime'; + +function getCSRFToken() { + var CSRFSelector = document.querySelector('meta[name="csrf-token"]'); + if (CSRFSelector) { + return CSRFSelector.getAttribute('content'); + } else { + return null; + } +} + +function hideFlashMessages() { + Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) { + flashMessage.classList.add('hidden'); + }); +} + +function callback(url, body) { + axios.post(url, JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': getCSRFToken(), + }, + credentials: 'same-origin', + }).then(function(response) { + window.location.replace(response.data.redirect_path); + }).catch(function(error) { + if (error.response.status === 422) { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error.response.data.error); + } else { + console.error(error); + } + }); +} + +ready(() => { + if (!WebAuthnJSON.supported()) { + const unsupported_browser_message = document.getElementById('unsupported-browser-message'); + if (unsupported_browser_message) { + unsupported_browser_message.classList.remove('hidden'); + document.querySelector('.btn.js-webauthn').disabled = true; + } + } + + + const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential'); + if (webAuthnCredentialRegistrationForm) { + webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]'); + if (nickname.value) { + axios.get('/settings/security_keys/options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'credential': credential, 'nickname': nickname.value }; + callback('/settings/security_keys', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + } else { + nickname.focus(); + } + }); + } + + const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form'); + if (webAuthnCredentialAuthenticationForm) { + webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + axios.get('sessions/security_key_options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'user': { 'credential': credential } }; + callback('sign_in', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + }); + + const otpAuthenticationForm = document.getElementById('otp-authentication-form'); + + const linkToOtp = document.getElementById('link-to-otp'); + linkToOtp.addEventListener('click', () => { + webAuthnCredentialAuthenticationForm.classList.add('hidden'); + otpAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + + const linkToWebAuthn = document.getElementById('link-to-webauthn'); + linkToWebAuthn.addEventListener('click', () => { + otpAuthenticationForm.classList.add('hidden'); + webAuthnCredentialAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + } +}); diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml index 1aa31df187..ca18fd7b86 100644 --- a/app/javascript/flavours/glitch/theme.yml +++ b/app/javascript/flavours/glitch/theme.yml @@ -1,26 +1,12 @@ -# (REQUIRED) The location of the pack files. -pack: - admin: - - packs/admin.tsx - - packs/public.tsx - auth: packs/public.tsx - common: - filename: packs/common.js - stylesheet: true - embed: packs/public.tsx - error: packs/error.js - home: - filename: packs/home.js - preload: - - flavours/glitch/async/compose - - flavours/glitch/async/home_timeline - - flavours/glitch/async/notifications - mailer: - modal: - public: packs/public.tsx - settings: packs/public.tsx - sign_up: packs/sign_up.js - share: packs/share.jsx +# (REQUIRED) The directory which contains the entry point files. +pack_directory: app/javascript/flavours/glitch/packs + +# (OPTIONAL) Define files to be preloaded when a logged-in user is +# visiting the main web app. +signed_in_preload: + - flavours/glitch/async/compose.js + - flavours/glitch/async/home_timeline.js + - flavours/glitch/async/notifications.js # (OPTIONAL) The directory which contains localization files for # the flavour, relative to this directory. The contents of this @@ -34,15 +20,3 @@ inherit_locales: vanilla # (OPTIONAL) A file to use as the preview screenshot for the flavour, # or an array thereof. These are the full path from `app/javascript/`. screenshot: flavours/glitch/images/glitch-preview.png - -# (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) By default the theme will fallback to the default theme -# if a particular pack is not provided. You can specify different -# fallbacks here, or disable fallback behaviours altogether by -# specifying a `null` value. -fallback: diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml index 7c7df295d3..d155324beb 100644 --- a/app/javascript/flavours/vanilla/theme.yml +++ b/app/javascript/flavours/vanilla/theme.yml @@ -1,26 +1,12 @@ -# (REQUIRED) The location of the pack files inside `pack_directory`. -pack: - admin: - - admin.tsx - - public.tsx - auth: public.tsx - common: - filename: common.js - stylesheet: true - embed: public.tsx - error: error.js - home: - filename: application.js - preload: - - features/compose - - features/home_timeline - - features/notifications - mailer: - modal: - public: public.tsx - settings: public.tsx - sign_up: sign_up.js - share: share.jsx +# (REQUIRED) The directory which contains the pack files. +pack_directory: app/javascript/packs + +# (OPTIONAL) Define files to be preloaded when a logged-in user is +# visiting the main web app. +signed_in_preload: + - features/compose.js + - features/home_timeline.js + - features/notifications.js # (OPTIONAL) The directory which contains localization files for # the flavour, relative to this directory. @@ -29,15 +15,3 @@ locales: ../../mastodon/locales # (OPTIONAL) A file to use as the preview screenshot for the flavour, # or an array thereof. These are the full path from `app/javascript/`. screenshot: images/screenshot.png - -# (OPTIONAL) The directory which contains the pack files. -# Defaults to this directory (`app/javascript/flavour/[flavour]`), -# but in the case of the vanilla Mastodon flavour the pack files are -# somewhere else. -pack_directory: app/javascript/packs - -# (OPTIONAL) By default the theme will fallback to the default flavour -# if a particular pack is not provided. You can specify different -# fallbacks here, or disable fallback behaviours altogether by -# specifying a `null` value. -fallback: diff --git a/app/javascript/packs/admin.tsx b/app/javascript/packs/admin.tsx index 13e740b190..9fee560565 100644 --- a/app/javascript/packs/admin.tsx +++ b/app/javascript/packs/admin.tsx @@ -1,8 +1,265 @@ import './public-path'; import { createRoot } from 'react-dom/client'; +import Rails from '@rails/ujs'; + import ready from '../mastodon/ready'; +const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { + const valid = target.value && target.validity.valid; + const element = document.querySelector( + 'input[type="datetime-local"]#announcement_ends_at', + ); + + if (!element) return; + + if (valid) { + element.classList.remove('optional'); + element.required = true; + element.min = target.value; + } else { + element.classList.add('optional'); + element.removeAttribute('required'); + element.removeAttribute('min'); + } +}; + +Rails.delegate( + document, + 'input[type="datetime-local"]#announcement_starts_at', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + setAnnouncementEndsAttributes(target); + }, +); + +const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; + +const showSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + selectAllMatchingElement?.classList.add('active'); +}; + +const hideSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + const hiddenField = document.querySelector( + 'input#select_all_matching', + ); + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + selectAllMatchingElement?.classList.remove('active'); + selectedMsg?.classList.remove('active'); + notSelectedMsg?.classList.add('active'); + if (hiddenField) hiddenField.value = '0'; +}; + +Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + document + .querySelectorAll(batchCheckboxClassName) + .forEach((content) => { + content.checked = target.checked; + }); + + if (selectAllMatchingElement) { + if (target.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } +}); + +Rails.delegate(document, '.batch-table__select-all button', 'click', () => { + const hiddenField = document.querySelector( + '#select_all_matching', + ); + + if (!hiddenField) return; + + const active = hiddenField.value === '1'; + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + if (!selectedMsg || !notSelectedMsg) return; + + if (active) { + hiddenField.value = '0'; + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + } else { + hiddenField.value = '1'; + notSelectedMsg.classList.remove('active'); + selectedMsg.classList.add('active'); + } +}); + +Rails.delegate(document, batchCheckboxClassName, 'change', () => { + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + + if (selectAllMatchingElement) { + if (checkAllElement.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } + } +}); + +Rails.delegate( + document, + '.filter-subset--with-select select', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) target.form?.submit(); + }, +); + +const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { + const rejectMediaDiv = document.querySelector( + '.input.with_label.domain_block_reject_media', + ); + const rejectReportsDiv = document.querySelector( + '.input.with_label.domain_block_reject_reports', + ); + + if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) { + rejectMediaDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } + + if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) { + rejectReportsDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } +}; + +Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { + if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); +}); + +const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { + const bootstrapTimelineAccountsField = + document.querySelector( + '#form_admin_settings_bootstrap_timeline_accounts', + ); + + if (bootstrapTimelineAccountsField) { + bootstrapTimelineAccountsField.disabled = !target.checked; + if (target.checked) { + bootstrapTimelineAccountsField.parentElement?.classList.remove( + 'disabled', + ); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + bootstrapTimelineAccountsField.parentElement?.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +Rails.delegate( + document, + '#form_admin_settings_enable_bootstrap_timeline_accounts', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + onEnableBootstrapTimelineAccountsChange(target); + }, +); + +const onChangeRegistrationMode = (target: HTMLSelectElement) => { + const enabled = target.value === 'approved'; + + document + .querySelectorAll( + '.form_admin_settings_registrations_mode .warning-hint', + ) + .forEach((warning_hint) => { + warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; + }); + + document + .querySelectorAll( + 'input#form_admin_settings_require_invite_text', + ) + .forEach((input) => { + input.disabled = !enabled; + if (enabled) { + let element: HTMLElement | null = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element: HTMLElement | null = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }); +}; + +const convertUTCDateTimeToLocal = (value: string) => { + const date = new Date(value + 'Z'); + const twoChars = (x: number) => x.toString().padStart(2, '0'); + return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; +}; + +function convertLocalDatetimeToUTC(value: string) { + const date = new Date(value); + const fullISO8601 = date.toISOString(); + return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); +} + +Rails.delegate( + document, + '#form_admin_settings_registrations_mode', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); + }, +); + async function mountReactComponent(element: Element) { const componentName = element.getAttribute('data-admin-component'); const stringProps = element.getAttribute('data-props'); @@ -29,6 +286,80 @@ async function mountReactComponent(element: Element) { } ready(() => { + const domainBlockSeveritySelect = document.querySelector( + 'select#domain_block_severity', + ); + if (domainBlockSeveritySelect) + onDomainBlockSeverityChange(domainBlockSeveritySelect); + + const enableBootstrapTimelineAccounts = + document.querySelector( + 'input#form_admin_settings_enable_bootstrap_timeline_accounts', + ); + if (enableBootstrapTimelineAccounts) + onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.querySelector( + 'select#form_admin_settings_registrations_mode', + ); + if (registrationMode) onChangeRegistrationMode(registrationMode); + + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + } + + document + .querySelector('a#add-instance-button') + ?.addEventListener('click', (e) => { + const domain = document.querySelector( + 'input[type="text"]#by_domain', + )?.value; + + if (domain && e.target instanceof HTMLAnchorElement) { + const url = new URL(e.target.href); + url.searchParams.set('_domain', domain); + e.target.href = url.toString(); + } + }); + + document + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value) { + element.value = convertUTCDateTimeToLocal(element.value); + } + if (element.placeholder) { + element.placeholder = convertUTCDateTimeToLocal(element.placeholder); + } + }); + + Rails.delegate(document, 'form', 'submit', ({ target }) => { + if (target instanceof HTMLFormElement) + target + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value && element.validity.valid) { + element.value = convertLocalDatetimeToUTC(element.value); + } + }); + }); + + const announcementStartsAt = document.querySelector( + 'input[type="datetime-local"]#announcement_starts_at', + ); + if (announcementStartsAt) { + setAnnouncementEndsAttributes(announcementStartsAt); + } + document.querySelectorAll('[data-admin-component]').forEach((element) => { void mountReactComponent(element); }); diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js index 05dff8e494..489041458f 100644 --- a/app/javascript/packs/common.js +++ b/app/javascript/packs/common.js @@ -1,2 +1,5 @@ +/* This file is a hack to have something more reliable than the upstream `common` tag + that is implicitly generated as the common chunk through webpack's `splitChunks` config */ + import './public-path'; -import 'styles/application.scss'; +import 'font-awesome/css/font-awesome.css'; diff --git a/app/javascript/core/inert.js b/app/javascript/packs/inert.js similarity index 100% rename from app/javascript/core/inert.js rename to app/javascript/packs/inert.js diff --git a/app/javascript/core/mailer.js b/app/javascript/packs/mailer.js similarity index 100% rename from app/javascript/core/mailer.js rename to app/javascript/packs/mailer.js diff --git a/app/javascript/packs/public.tsx b/app/javascript/packs/public.tsx index 17befbd224..044faeb296 100644 --- a/app/javascript/packs/public.tsx +++ b/app/javascript/packs/public.tsx @@ -37,6 +37,43 @@ const messages = defineMessages({ }, }); +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + ready(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0].scrollHeight, + }, + '*', + ); + }).catch((e) => { + console.error('Error in setHeightMessage postMessage', e); + }); +}); + function loaded() { const { messages: localeData } = getLocale(); @@ -288,6 +325,72 @@ function loaded() { }); } +Rails.delegate( + document, + '#edit_profile input[type=file]', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const avatar = document.querySelector( + `img#${target.id}-preview`, + ); + + if (!avatar) return; + + let file: File | undefined; + if (target.files) file = target.files[0]; + + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + if (url) avatar.src = url; + }, +); + +Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { + if (!(target instanceof HTMLButtonElement)) return; + + const input = target.parentNode?.querySelector( + '.input-copy__wrapper input', + ); + + if (!input) return; + + const oldReadOnly = input.readOnly; + + input.readOnly = false; + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (document.execCommand('copy')) { + input.blur(); + + const parent = target.parentElement; + + if (!parent) return; + parent.classList.add('copied'); + + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + + input.readOnly = oldReadOnly; +}); + const toggleSidebar = () => { const sidebar = document.querySelector('.sidebar ul'); const toggleButton = document.querySelector( diff --git a/app/javascript/packs/remote_interaction_helper.ts b/app/javascript/packs/remote_interaction_helper.ts new file mode 100644 index 0000000000..d5834c6c3d --- /dev/null +++ b/app/javascript/packs/remote_interaction_helper.ts @@ -0,0 +1,174 @@ +/* + +This script is meant to to be used in an `iframe` with the sole purpose of doing webfinger queries +client-side without being restricted by a strict `connect-src` Content-Security-Policy directive. + +It communicates with the parent window through message events that are authenticated by origin, +and performs no other task. + +*/ + +import './public-path'; + +import axios from 'axios'; + +interface JRDLink { + rel: string; + template?: string; + href?: string; +} + +const isJRDLink = (link: unknown): link is JRDLink => + typeof link === 'object' && + link !== null && + 'rel' in link && + typeof link.rel === 'string' && + (!('template' in link) || typeof link.template === 'string') && + (!('href' in link) || typeof link.href === 'string'); + +const findLink = (rel: string, data: unknown): JRDLink | undefined => { + if ( + typeof data === 'object' && + data !== null && + 'links' in data && + data.links instanceof Array + ) { + return data.links.find( + (link): link is JRDLink => isJRDLink(link) && link.rel === rel, + ); + } else { + return undefined; + } +}; + +const findTemplateLink = (data: unknown) => + findLink('http://ostatus.org/schema/1.0/subscribe', data)?.template; + +const fetchInteractionURLSuccess = ( + uri_or_domain: string, + template: string, +) => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-success', + uri_or_domain, + template, + }, + window.origin, + ); +}; + +const fetchInteractionURLFailure = () => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-failure', + }, + window.origin, + ); +}; + +const isValidDomain = (value: string) => { + const url = new URL('https:///path'); + url.hostname = value; + return url.hostname === value; +}; + +// Attempt to find a remote interaction URL from a domain +const fromDomain = (domain: string) => { + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `https://${domain}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(domain, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fetchInteractionURLSuccess(domain, fallbackTemplate); + }); +}; + +// Attempt to find a remote interaction URL from an arbitrary URL +const fromURL = (url: string) => { + const domain = new URL(url).host; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: url }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(url, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fromDomain(domain); + }); +}; + +// Attempt to find a remote interaction URL from a `user@domain` string +const fromAcct = (acct: string) => { + acct = acct.replace(/^@/, ''); + + const segments = acct.split('@'); + + if (segments.length !== 2 || !segments[0] || !isValidDomain(segments[1])) { + fetchInteractionURLFailure(); + return; + } + + const domain = segments[1]; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `acct:${acct}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(acct, template ?? fallbackTemplate); + return; + }) + .catch(() => { + // TODO: handle host-meta? + fromDomain(domain); + }); +}; + +const fetchInteractionURL = (uri_or_domain: string) => { + if (uri_or_domain === '') { + fetchInteractionURLFailure(); + } else if (/^https?:\/\//.test(uri_or_domain)) { + fromURL(uri_or_domain); + } else if (uri_or_domain.includes('@')) { + fromAcct(uri_or_domain); + } else { + fromDomain(uri_or_domain); + } +}; + +window.addEventListener('message', (event: MessageEvent) => { + // Check message origin + if ( + !window.origin || + window.parent !== event.source || + event.origin !== window.origin + ) { + return; + } + + if ( + event.data && + typeof event.data === 'object' && + 'type' in event.data && + event.data.type === 'fetchInteractionURL' && + 'uri_or_domain' in event.data && + typeof event.data.uri_or_domain === 'string' + ) { + fetchInteractionURL(event.data.uri_or_domain); + } +}); diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/packs/two_factor_authentication.js similarity index 99% rename from app/javascript/core/two_factor_authentication.js rename to app/javascript/packs/two_factor_authentication.js index e76700a480..e77965c757 100644 --- a/app/javascript/core/two_factor_authentication.js +++ b/app/javascript/packs/two_factor_authentication.js @@ -1,5 +1,3 @@ -import 'packs/public-path'; - import * as WebAuthnJSON from '@github/webauthn-json'; import axios from 'axios'; diff --git a/app/javascript/skins/glitch/default/common.scss b/app/javascript/skins/glitch/default/common.scss new file mode 100644 index 0000000000..d2f673fde8 --- /dev/null +++ b/app/javascript/skins/glitch/default/common.scss @@ -0,0 +1 @@ +@import 'flavours/glitch/styles/index'; diff --git a/app/javascript/skins/vanilla/default/common.scss b/app/javascript/skins/vanilla/default/common.scss new file mode 100644 index 0000000000..71ac01180a --- /dev/null +++ b/app/javascript/skins/vanilla/default/common.scss @@ -0,0 +1 @@ +@import 'styles/application'; diff --git a/app/lib/themes.rb b/app/lib/themes.rb index 45ba47780b..89730c9b31 100644 --- a/app/lib/themes.rb +++ b/app/lib/themes.rb @@ -7,13 +7,11 @@ class Themes include Singleton def initialize - core = YAML.load_file(Rails.root.join('app', 'javascript', 'core', 'theme.yml')) - core['pack'] = {} unless core['pack'] + @flavours = {} - result = {} Rails.root.glob('app/javascript/flavours/*/theme.yml') do |pathname| data = YAML.load_file(pathname) - next unless data['pack'] + next unless data['pack_directory'] dir = pathname.dirname name = dir.basename.to_s @@ -38,45 +36,34 @@ class Themes data['name'] = name data['locales'] = locales data['screenshot'] = screenshots - data['skin'] = { 'default' => [] } - result[name] = data + data['skins'] = [] + @flavours[name] = data end Rails.root.glob('app/javascript/skins/*/*') do |pathname| ext = pathname.extname.to_s skin = pathname.basename.to_s name = pathname.dirname.basename.to_s - next unless result[name] + next unless @flavours[name] if pathname.directory? - pack = [] - pathname.glob('*.{css,scss}') do |sheet| - pack.push(sheet.basename(sheet.extname).to_s) - end + @flavours[name]['skins'] << skin if pathname.glob('{common,index,application}.{css,scss}').any? elsif /^\.s?css$/i.match?(ext) - skin = pathname.basename(ext).to_s - pack = ['common'] + @flavours[name]['skins'] << pathname.basename(ext).to_s end - - result[name]['skin'][skin] = pack if skin != 'default' end - - @core = core - @conf = result end - attr_reader :core - def flavour(name) - @conf[name] + @flavours[name] end def flavours - @conf.keys + @flavours.keys end def skins_for(name) - @conf[name]['skin'].keys + @flavours[name]['skins'] end def flavours_and_skins diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml index 0892101b57..63c8b0a2cb 100644 --- a/app/views/auth/sessions/two_factor.html.haml +++ b/app/views/auth/sessions/two_factor.html.haml @@ -1,6 +1,8 @@ - content_for :page_title do = t('auth.login') += flavoured_javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous' + - if webauthn_enabled? = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' } diff --git a/app/views/auth/setup/show.html.haml b/app/views/auth/setup/show.html.haml index 097e3416e2..6ba219e729 100644 --- a/app/views/auth/setup/show.html.haml +++ b/app/views/auth/setup/show.html.haml @@ -1,6 +1,8 @@ - content_for :page_title do = t('auth.setup.title') += flavoured_javascript_pack_tag 'sign_up', crossorigin: 'anonymous' + = simple_form_for(@user, url: auth_setup_path) do |f| = render 'auth/shared/progress', stage: 'confirm' diff --git a/app/views/layouts/_theme.html.haml b/app/views/layouts/_theme.html.haml deleted file mode 100644 index 2b40d408f2..0000000000 --- a/app/views/layouts/_theme.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -- if theme - = render partial: 'layouts/theme', object: theme[:common] if theme[:pack] != 'common' && theme[:common] - - if theme[:pack] - - pack_path = theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}" - = javascript_pack_tag pack_path, crossorigin: 'anonymous' - - if theme[:skin] - - if !theme[:flavour] || theme[:skin] == 'default' - = stylesheet_pack_tag pack_path, media: 'all', crossorigin: 'anonymous' - - else - = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}", media: 'all', crossorigin: 'anonymous' - - theme[:preload]&.each do |link| - %link{ href: asset_pack_path("#{link}.js"), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 3048e0e6ad..a95d5caef5 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,5 +1,7 @@ - content_for :header_tags do = render_initial_state + = flavoured_javascript_pack_tag 'public', crossorigin: 'anonymous' + = flavoured_javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - content_for :content do .admin-wrapper diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 3e11e79052..7d69ea88e6 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -26,27 +26,21 @@ %title= html_title - = javascript_pack_tag 'common', crossorigin: 'anonymous' + = flavoured_stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous' # upstream uses `common` but that's implicitly defined + = stylesheet_pack_tag current_theme, media: 'all', crossorigin: 'anonymous' -# Needed for the wicg-inert polyfill. It needs to be on it's own