Merge pull request #2697 from ClearlyClaire/glitch-soc/refactor/revamp-theming-system

Simplify glitch-soc's theming system
This commit is contained in:
Claire 2024-04-30 20:41:38 +02:00 committed by GitHub
commit 6f74ede26b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 1333 additions and 882 deletions

View file

@ -7,7 +7,6 @@ module Admin
layout 'admin' layout 'admin'
before_action :set_pack
before_action :set_body_classes before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
@ -19,10 +18,6 @@ module Admin
@body_classes = 'admin' @body_classes = 'admin'
end end
def set_pack
use_pack 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View file

@ -19,6 +19,7 @@ class ApplicationController < ActionController::Base
helper_method :current_session helper_method :current_session
helper_method :current_flavour helper_method :current_flavour
helper_method :current_skin helper_method :current_skin
helper_method :current_theme
helper_method :single_user_mode? helper_method :single_user_mode?
helper_method :use_seamless_external_login? helper_method :use_seamless_external_login?
helper_method :omniauth_only? helper_method :omniauth_only?
@ -164,10 +165,7 @@ class ApplicationController < ActionController::Base
def respond_with_error(code) def respond_with_error(code)
respond_to do |format| respond_to do |format|
format.any do format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
use_pack 'error'
render "errors/#{code}", layout: 'error', status: code, formats: [:html]
end
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code }
end end
end end
@ -176,10 +174,7 @@ class ApplicationController < ActionController::Base
return unless self_destruct? return unless self_destruct?
respond_to do |format| respond_to do |format|
format.any do format.any { render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] }
use_pack 'error'
render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html]
end
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 } format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 }
end end
end end

View file

@ -5,7 +5,6 @@ class Auth::ChallengesController < ApplicationController
layout 'auth' layout 'auth'
before_action :set_pack
before_action :authenticate_user! before_action :authenticate_user!
skip_before_action :check_self_destruct! skip_before_action :check_self_destruct!
@ -21,10 +20,4 @@ class Auth::ChallengesController < ApplicationController
render_challenge render_challenge
end end
end end
private
def set_pack
use_pack 'auth'
end
end end

View file

@ -6,7 +6,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth' layout 'auth'
before_action :set_body_classes before_action :set_body_classes
before_action :set_pack
before_action :set_confirmation_user!, only: [:show, :confirm_captcha] before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? 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? @confirmation_user.nil? || @confirmation_user.confirmed?
end end
def set_pack
use_pack 'auth'
end
def redirect_confirmed_user def redirect_confirmed_user
redirect_to(current_user.approved? ? root_path : edit_user_registration_path) redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
end end

View file

@ -3,7 +3,6 @@
class Auth::PasswordsController < Devise::PasswordsController class Auth::PasswordsController < Devise::PasswordsController
skip_before_action :check_self_destruct! skip_before_action :check_self_destruct!
before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid?
before_action :set_pack
before_action :set_body_classes before_action :set_body_classes
layout 'auth' layout 'auth'
@ -32,8 +31,4 @@ class Auth::PasswordsController < Devise::PasswordsController
def reset_password_token_is_valid? def reset_password_token_is_valid?
resource_class.with_reset_password_token(params[:reset_password_token]).present? resource_class.with_reset_password_token(params[:reset_password_token]).present?
end end
def set_pack
use_pack 'auth'
end
end end

View file

@ -9,7 +9,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :set_invite, only: [:new, :create] before_action :set_invite, only: [:new, :create]
before_action :check_enabled_registrations, only: [:new, :create] before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create] before_action :configure_sign_up_params, only: [:create]
before_action :set_pack
before_action :set_sessions, only: [:edit, :update] before_action :set_sessions, only: [:edit, :update]
before_action :set_strikes, only: [:edit, :update] before_action :set_strikes, only: [:edit, :update]
before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update]
@ -97,10 +96,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
private private
def set_pack
use_pack %w(edit update).include?(action_name) ? 'admin' : 'auth'
end
def set_body_classes def set_body_classes
@body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter' @body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter'
end end

View file

@ -12,7 +12,6 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional! skip_before_action :require_functional!
skip_before_action :update_user_sign_in skip_before_action :update_user_sign_in
prepend_before_action :set_pack
prepend_before_action :check_suspicious!, only: [:create] prepend_before_action :check_suspicious!, only: [:create]
include Auth::TwoFactorAuthenticationConcern include Auth::TwoFactorAuthenticationConcern
@ -104,10 +103,6 @@ class Auth::SessionsController < Devise::SessionsController
private private
def set_pack
use_pack 'auth'
end
def set_body_classes def set_body_classes
@body_classes = 'lighter' @body_classes = 'lighter'
end end

View file

@ -3,7 +3,6 @@
class Auth::SetupController < ApplicationController class Auth::SetupController < ApplicationController
layout 'auth' layout 'auth'
before_action :set_pack
before_action :authenticate_user! before_action :authenticate_user!
before_action :require_unconfirmed_or_pending! before_action :require_unconfirmed_or_pending!
before_action :set_body_classes before_action :set_body_classes
@ -43,8 +42,4 @@ class Auth::SetupController < ApplicationController
def user_params def user_params
params.require(:user).permit(:email) params.require(:user).permit(:email)
end end
def set_pack
use_pack 'sign_up'
end
end end

View file

@ -83,8 +83,6 @@ module Auth::TwoFactorAuthenticationConcern
def prompt_for_two_factor(user) def prompt_for_two_factor(user)
register_attempt_in_session(user) register_attempt_in_session(user)
use_pack 'auth'
@body_classes = 'lighter' @body_classes = 'lighter'
@webauthn_enabled = user.webauthn_enabled? @webauthn_enabled = user.webauthn_enabled?
@scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?

View file

@ -3,87 +3,22 @@
module ThemingConcern module ThemingConcern
extend ActiveSupport::Concern 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 private
def current_flavour 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 end
def current_skin def current_skin
@current_skin ||= begin
skins = Themes.instance.skins_for(current_flavour) skins = Themes.instance.skins_for(current_flavour)
[current_user&.setting_skin, Setting.skin, 'default'].find { |skin| skins.include?(skin) } [current_user&.setting_skin, Setting.skin, 'default'].find { |skin| skins.include?(skin) }
end end
def valid_pack_data?(data, pack_name)
data['pack'].is_a?(Hash) && data['pack'][pack_name].present?
end end
def nil_pack(data) def current_theme
{ # NOTE: this is slightly different from upstream, as it's a derived value used
use_common: true, # for the sole purpose of pointing to the appropriate stylesheet pack
flavour: data['name'], "skins/#{current_flavour}/#{current_skin}"
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'
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
end end
end end

View file

@ -7,7 +7,6 @@ module WebAppControllerConcern
vary_by 'Accept, Accept-Language, Cookie' vary_by 'Accept, Accept-Language, Cookie'
before_action :redirect_unauthenticated_to_permalinks! before_action :redirect_unauthenticated_to_permalinks!
before_action :set_pack
before_action :set_app_body_class before_action :set_app_body_class
end end
@ -37,8 +36,4 @@ module WebAppControllerConcern
end end
end end
end end
def set_pack
use_pack 'home'
end
end end

View file

@ -9,15 +9,10 @@ class Disputes::BaseController < ApplicationController
before_action :set_body_classes before_action :set_body_classes
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_pack
before_action :set_cache_headers before_action :set_cache_headers
private private
def set_pack
use_pack 'admin'
end
def set_body_classes def set_body_classes
@body_classes = 'admin' @body_classes = 'admin'
end end

View file

@ -6,7 +6,6 @@ class Filters::StatusesController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_filter before_action :set_filter
before_action :set_status_filters before_action :set_status_filters
before_action :set_pack
before_action :set_body_classes before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
@ -27,10 +26,6 @@ class Filters::StatusesController < ApplicationController
private private
def set_pack
use_pack 'admin'
end
def set_filter def set_filter
@filter = current_account.custom_filters.find(params[:filter_id]) @filter = current_account.custom_filters.find(params[:filter_id])
end end

View file

@ -5,7 +5,6 @@ class FiltersController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_filter, only: [:edit, :update, :destroy] before_action :set_filter, only: [:edit, :update, :destroy]
before_action :set_pack
before_action :set_body_classes before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
@ -45,10 +44,6 @@ class FiltersController < ApplicationController
private private
def set_pack
use_pack 'settings'
end
def set_filter def set_filter
@filter = current_account.custom_filters.find(params[:id]) @filter = current_account.custom_filters.find(params[:id])
end end

View file

@ -6,7 +6,6 @@ class InvitesController < ApplicationController
layout 'admin' layout 'admin'
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_pack
before_action :set_body_classes before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
@ -40,10 +39,6 @@ class InvitesController < ApplicationController
private private
def set_pack
use_pack 'settings'
end
def invites def invites
current_user.invites.order(id: :desc) current_user.invites.order(id: :desc)
end end

View file

@ -10,7 +10,6 @@ class MediaController < ApplicationController
before_action :verify_permitted_status! before_action :verify_permitted_status!
before_action :check_playable, only: :player before_action :check_playable, only: :player
before_action :allow_iframing, only: :player before_action :allow_iframing, only: :player
before_action :set_pack, only: :player
content_security_policy only: :player do |policy| content_security_policy only: :player do |policy|
policy.frame_ancestors(false) policy.frame_ancestors(false)
@ -48,8 +47,4 @@ class MediaController < ApplicationController
def allow_iframing def allow_iframing
response.headers.delete('X-Frame-Options') response.headers.delete('X-Frame-Options')
end end
def set_pack
use_pack 'public'
end
end end

View file

@ -5,7 +5,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
before_action :store_current_location before_action :store_current_location
before_action :authenticate_resource_owner! before_action :authenticate_resource_owner!
before_action :set_pack
before_action :set_cache_headers before_action :set_cache_headers
content_security_policy do |p| content_security_policy do |p|
@ -20,10 +19,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
store_location_for(:user, request.url) store_location_for(:user, request.url)
end end
def set_pack
use_pack 'auth'
end
def render_success def render_success
if skip_authorization? || (matching_token? && !truthy_param?('force_login')) if skip_authorization? || (matching_token? && !truthy_param?('force_login'))
redirect_or_render authorize_response redirect_or_render authorize_response

View file

@ -5,7 +5,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location before_action :store_current_location
before_action :authenticate_resource_owner! before_action :authenticate_resource_owner!
before_action :set_pack
before_action :require_not_suspended!, only: :destroy before_action :require_not_suspended!, only: :destroy
before_action :set_body_classes before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
@ -31,10 +30,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
store_location_for(:user, request.url) store_location_for(:user, request.url)
end end
def set_pack
use_pack 'settings'
end
def require_not_suspended! def require_not_suspended!
forbidden if current_account.unavailable? forbidden if current_account.unavailable?
end end

View file

@ -3,7 +3,6 @@
class Redirect::BaseController < ApplicationController class Redirect::BaseController < ApplicationController
vary_by 'Accept-Language' vary_by 'Accept-Language'
before_action :set_pack
before_action :set_resource before_action :set_resource
before_action :set_app_body_class before_action :set_app_body_class
@ -22,8 +21,4 @@ class Redirect::BaseController < ApplicationController
def set_resource def set_resource
raise NotImplementedError raise NotImplementedError
end end
def set_pack
use_pack 'public'
end
end end

View file

@ -5,7 +5,6 @@ class RelationshipsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_accounts, only: :show before_action :set_accounts, only: :show
before_action :set_pack
before_action :set_relationships, only: :show before_action :set_relationships, only: :show
before_action :set_body_classes before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
@ -73,10 +72,6 @@ class RelationshipsController < ApplicationController
@body_classes = 'admin' @body_classes = 'admin'
end end
def set_pack
use_pack 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View file

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::BaseController < ApplicationController class Settings::BaseController < ApplicationController
before_action :set_pack
layout 'admin' layout 'admin'
before_action :authenticate_user! before_action :authenticate_user!
@ -10,10 +9,6 @@ class Settings::BaseController < ApplicationController
private private
def set_pack
use_pack 'settings'
end
def set_body_classes def set_body_classes
@body_classes = 'admin' @body_classes = 'admin'
end end

View file

@ -7,10 +7,4 @@ class Settings::LoginActivitiesController < Settings::BaseController
def index def index
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page]) @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
end end
private
def set_pack
use_pack 'settings'
end
end end

View file

@ -85,10 +85,6 @@ module Settings
private private
def set_pack
use_pack 'auth'
end
def redirect_invalid_otp def redirect_invalid_otp
flash[:error] = t('webauthn_credentials.otp_required') flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path redirect_to settings_two_factor_authentication_methods_path

View file

@ -4,17 +4,12 @@ class SharesController < ApplicationController
layout 'modal' layout 'modal'
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_pack
before_action :set_body_classes before_action :set_body_classes
def show; end def show; end
private private
def set_pack
use_pack 'share'
end
def set_body_classes def set_body_classes
@body_classes = 'modal-layout compose-standalone' @body_classes = 'modal-layout compose-standalone'
end end

View file

@ -6,7 +6,6 @@ class StatusesCleanupController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_policy before_action :set_policy
before_action :set_body_classes before_action :set_body_classes
before_action :set_pack
before_action :set_cache_headers before_action :set_cache_headers
def show; end def show; end
@ -27,10 +26,6 @@ class StatusesCleanupController < ApplicationController
private private
def set_pack
use_pack 'settings'
end
def set_policy def set_policy
@policy = current_account.statuses_cleanup_policy || current_account.build_statuses_cleanup_policy(enabled: false) @policy = current_account.statuses_cleanup_policy || current_account.build_statuses_cleanup_policy(enabled: false)
end end

View file

@ -41,7 +41,6 @@ class StatusesController < ApplicationController
end end
def embed def embed
use_pack 'embed'
return not_found if @status.hidden? || @status.reblog? return not_found if @status.hidden? || @status.reblog?
expires_in 180, public: true expires_in 180, public: true

View file

@ -233,6 +233,25 @@ module ApplicationHelper
EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s
end 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 private
def storage_host_var def storage_host_var

View file

@ -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<HTMLInputElement>(
'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<HTMLInputElement>(
'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<HTMLInputElement>(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<HTMLInputElement>(
'#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<HTMLInputElement>(
'input#batch_checkbox_all',
);
const selectAllMatchingElement = document.querySelector(
'.batch-table__select-all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
'#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<HTMLElement>(
'.form_admin_settings_registrations_mode .warning-hint',
)
.forEach((warning_hint) => {
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
});
document
.querySelectorAll<HTMLInputElement>(
'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<HTMLSelectElement>(
'select#domain_block_severity',
);
if (domainBlockSeveritySelect)
onDomainBlockSeverityChange(domainBlockSeveritySelect);
const enableBootstrapTimelineAccounts =
document.querySelector<HTMLInputElement>(
'input#form_admin_settings_enable_bootstrap_timeline_accounts',
);
if (enableBootstrapTimelineAccounts)
onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
const registrationMode = document.querySelector<HTMLSelectElement>(
'select#form_admin_settings_registrations_mode',
);
if (registrationMode) onChangeRegistrationMode(registrationMode);
const checkAllElement = document.querySelector<HTMLInputElement>(
'input#batch_checkbox_all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
'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<HTMLInputElement>('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<HTMLInputElement>('input[type="datetime-local"]')
.forEach((element) => {
if (element.value && element.validity.valid) {
element.value = convertLocalDatetimeToUTC(element.value);
}
});
});
const announcementStartsAt = document.querySelector<HTMLInputElement>(
'input[type="datetime-local"]#announcement_starts_at',
);
if (announcementStartsAt) {
setAnnouncementEndsAttributes(announcementStartsAt);
}
}).catch((reason) => {
throw reason;
});

View file

@ -1,3 +0,0 @@
import 'packs/public-path';
import './settings';
import './two_factor_authentication';

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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<HTMLImageElement>(
`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<HTMLInputElement>(
'.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;
});

View file

@ -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

View file

@ -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
}
}

View file

@ -1,8 +1,265 @@
import 'packs/public-path'; import 'packs/public-path';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
import ready from 'flavours/glitch/ready'; import ready from 'flavours/glitch/ready';
const setAnnouncementEndsAttributes = (target: HTMLInputElement) => {
const valid = target.value && target.validity.valid;
const element = document.querySelector<HTMLInputElement>(
'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<HTMLInputElement>(
'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<HTMLInputElement>(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<HTMLInputElement>(
'#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<HTMLInputElement>(
'input#batch_checkbox_all',
);
const selectAllMatchingElement = document.querySelector(
'.batch-table__select-all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
'#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<HTMLElement>(
'.form_admin_settings_registrations_mode .warning-hint',
)
.forEach((warning_hint) => {
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
});
document
.querySelectorAll<HTMLInputElement>(
'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) { async function mountReactComponent(element: Element) {
const componentName = element.getAttribute('data-admin-component'); const componentName = element.getAttribute('data-admin-component');
const stringProps = element.getAttribute('data-props'); const stringProps = element.getAttribute('data-props');
@ -29,6 +286,80 @@ async function mountReactComponent(element: Element) {
} }
ready(() => { ready(() => {
const domainBlockSeveritySelect = document.querySelector<HTMLSelectElement>(
'select#domain_block_severity',
);
if (domainBlockSeveritySelect)
onDomainBlockSeverityChange(domainBlockSeveritySelect);
const enableBootstrapTimelineAccounts =
document.querySelector<HTMLInputElement>(
'input#form_admin_settings_enable_bootstrap_timeline_accounts',
);
if (enableBootstrapTimelineAccounts)
onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
const registrationMode = document.querySelector<HTMLSelectElement>(
'select#form_admin_settings_registrations_mode',
);
if (registrationMode) onChangeRegistrationMode(registrationMode);
const checkAllElement = document.querySelector<HTMLInputElement>(
'input#batch_checkbox_all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
'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<HTMLInputElement>('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<HTMLInputElement>('input[type="datetime-local"]')
.forEach((element) => {
if (element.value && element.validity.valid) {
element.value = convertLocalDatetimeToUTC(element.value);
}
});
});
const announcementStartsAt = document.querySelector<HTMLInputElement>(
'input[type="datetime-local"]#announcement_starts_at',
);
if (announcementStartsAt) {
setAnnouncementEndsAttributes(announcementStartsAt);
}
document.querySelectorAll('[data-admin-component]').forEach((element) => { document.querySelectorAll('[data-admin-component]').forEach((element) => {
void mountReactComponent(element); void mountReactComponent(element);
}); });

View file

@ -1,8 +1,12 @@
import 'packs/public-path'; import 'packs/public-path';
import { start } from 'flavours/glitch/common';
import { loadLocale } from 'flavours/glitch/locales'; import { loadLocale } from 'flavours/glitch/locales';
import main from "flavours/glitch/main"; import main from "flavours/glitch/main";
import { loadPolyfills } from 'flavours/glitch/polyfills'; import { loadPolyfills } from 'flavours/glitch/polyfills';
start();
loadPolyfills() loadPolyfills()
.then(loadLocale) .then(loadLocale)
.then(main) .then(main)

View file

@ -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 'packs/public-path';
import Rails from '@rails/ujs'; import 'font-awesome/css/font-awesome.css';
import 'flavours/glitch/styles/index.scss';
Rails.start(); // This is a hack to ensures that webpack compiles our images.
// This ensures that webpack compiles our images.
require.context('../images', true); require.context('../images', true);

View file

@ -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';

View file

@ -0,0 +1,3 @@
import '@/styles/mailer.scss';
require.context('@/icons');

View file

@ -10,6 +10,7 @@ import Rails from '@rails/ujs';
import axios from 'axios'; import axios from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { start } from 'flavours/glitch/common';
import { timeAgoString } from 'flavours/glitch/components/relative_timestamp'; import { timeAgoString } from 'flavours/glitch/components/relative_timestamp';
import emojify from 'flavours/glitch/features/emoji/emoji'; import emojify from 'flavours/glitch/features/emoji/emoji';
import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions'; import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
@ -19,6 +20,8 @@ import ready from 'flavours/glitch/ready';
import 'cocoon-js-vanilla'; import 'cocoon-js-vanilla';
start();
const messages = defineMessages({ const messages = defineMessages({
usernameTaken: { usernameTaken: {
id: 'username.taken', 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() { function loaded() {
const { messages: localeData } = getLocale(); 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<HTMLImageElement>(
`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<HTMLInputElement>(
'.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 toggleSidebar = () => {
const sidebar = document.querySelector<HTMLUListElement>('.sidebar ul'); const sidebar = document.querySelector<HTMLUListElement>('.sidebar ul');
const toggleButton = document.querySelector<HTMLAnchorElement>( const toggleButton = document.querySelector<HTMLAnchorElement>(

View file

@ -1,10 +1,13 @@
import 'packs/public-path'; import 'packs/public-path';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { start } from 'flavours/glitch/common';
import ComposeContainer from 'flavours/glitch/containers/compose_container'; import ComposeContainer from 'flavours/glitch/containers/compose_container';
import { loadPolyfills } from 'flavours/glitch/polyfills'; import { loadPolyfills } from 'flavours/glitch/polyfills';
import ready from 'flavours/glitch/ready'; import ready from 'flavours/glitch/ready';
start();
function loaded() { function loaded() {
const mountNode = document.getElementById('mastodon-compose'); const mountNode = document.getElementById('mastodon-compose');

View file

@ -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();
});
}
});

View file

@ -1,26 +1,12 @@
# (REQUIRED) The location of the pack files. # (REQUIRED) The directory which contains the entry point files.
pack: pack_directory: app/javascript/flavours/glitch/packs
admin:
- packs/admin.tsx # (OPTIONAL) Define files to be preloaded when a logged-in user is
- packs/public.tsx # visiting the main web app.
auth: packs/public.tsx signed_in_preload:
common: - flavours/glitch/async/compose.js
filename: packs/common.js - flavours/glitch/async/home_timeline.js
stylesheet: true - flavours/glitch/async/notifications.js
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
# (OPTIONAL) The directory which contains localization files for # (OPTIONAL) The directory which contains localization files for
# the flavour, relative to this directory. The contents of this # 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, # (OPTIONAL) A file to use as the preview screenshot for the flavour,
# or an array thereof. These are the full path from `app/javascript/`. # or an array thereof. These are the full path from `app/javascript/`.
screenshot: flavours/glitch/images/glitch-preview.png 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:

View file

@ -1,26 +1,12 @@
# (REQUIRED) The location of the pack files inside `pack_directory`. # (REQUIRED) The directory which contains the pack files.
pack: pack_directory: app/javascript/packs
admin:
- admin.tsx # (OPTIONAL) Define files to be preloaded when a logged-in user is
- public.tsx # visiting the main web app.
auth: public.tsx signed_in_preload:
common: - features/compose.js
filename: common.js - features/home_timeline.js
stylesheet: true - features/notifications.js
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
# (OPTIONAL) The directory which contains localization files for # (OPTIONAL) The directory which contains localization files for
# the flavour, relative to this directory. # 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, # (OPTIONAL) A file to use as the preview screenshot for the flavour,
# or an array thereof. These are the full path from `app/javascript/`. # or an array thereof. These are the full path from `app/javascript/`.
screenshot: images/screenshot.png 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:

View file

@ -1,8 +1,265 @@
import './public-path'; import './public-path';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
const setAnnouncementEndsAttributes = (target: HTMLInputElement) => {
const valid = target.value && target.validity.valid;
const element = document.querySelector<HTMLInputElement>(
'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<HTMLInputElement>(
'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<HTMLInputElement>(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<HTMLInputElement>(
'#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<HTMLInputElement>(
'input#batch_checkbox_all',
);
const selectAllMatchingElement = document.querySelector(
'.batch-table__select-all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
'#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<HTMLElement>(
'.form_admin_settings_registrations_mode .warning-hint',
)
.forEach((warning_hint) => {
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
});
document
.querySelectorAll<HTMLInputElement>(
'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) { async function mountReactComponent(element: Element) {
const componentName = element.getAttribute('data-admin-component'); const componentName = element.getAttribute('data-admin-component');
const stringProps = element.getAttribute('data-props'); const stringProps = element.getAttribute('data-props');
@ -29,6 +286,80 @@ async function mountReactComponent(element: Element) {
} }
ready(() => { ready(() => {
const domainBlockSeveritySelect = document.querySelector<HTMLSelectElement>(
'select#domain_block_severity',
);
if (domainBlockSeveritySelect)
onDomainBlockSeverityChange(domainBlockSeveritySelect);
const enableBootstrapTimelineAccounts =
document.querySelector<HTMLInputElement>(
'input#form_admin_settings_enable_bootstrap_timeline_accounts',
);
if (enableBootstrapTimelineAccounts)
onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
const registrationMode = document.querySelector<HTMLSelectElement>(
'select#form_admin_settings_registrations_mode',
);
if (registrationMode) onChangeRegistrationMode(registrationMode);
const checkAllElement = document.querySelector<HTMLInputElement>(
'input#batch_checkbox_all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
'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<HTMLInputElement>('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<HTMLInputElement>('input[type="datetime-local"]')
.forEach((element) => {
if (element.value && element.validity.valid) {
element.value = convertLocalDatetimeToUTC(element.value);
}
});
});
const announcementStartsAt = document.querySelector<HTMLInputElement>(
'input[type="datetime-local"]#announcement_starts_at',
);
if (announcementStartsAt) {
setAnnouncementEndsAttributes(announcementStartsAt);
}
document.querySelectorAll('[data-admin-component]').forEach((element) => { document.querySelectorAll('[data-admin-component]').forEach((element) => {
void mountReactComponent(element); void mountReactComponent(element);
}); });

View file

@ -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 './public-path';
import 'styles/application.scss'; import 'font-awesome/css/font-awesome.css';

View file

@ -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() { function loaded() {
const { messages: localeData } = getLocale(); 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<HTMLImageElement>(
`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<HTMLInputElement>(
'.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 toggleSidebar = () => {
const sidebar = document.querySelector<HTMLUListElement>('.sidebar ul'); const sidebar = document.querySelector<HTMLUListElement>('.sidebar ul');
const toggleButton = document.querySelector<HTMLAnchorElement>( const toggleButton = document.querySelector<HTMLAnchorElement>(

View file

@ -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<unknown>) => {
// 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);
}
});

View file

@ -1,5 +1,3 @@
import 'packs/public-path';
import * as WebAuthnJSON from '@github/webauthn-json'; import * as WebAuthnJSON from '@github/webauthn-json';
import axios from 'axios'; import axios from 'axios';

View file

@ -0,0 +1 @@
@import 'flavours/glitch/styles/index';

View file

@ -0,0 +1 @@
@import 'styles/application';

View file

@ -7,13 +7,11 @@ class Themes
include Singleton include Singleton
def initialize def initialize
core = YAML.load_file(Rails.root.join('app', 'javascript', 'core', 'theme.yml')) @flavours = {}
core['pack'] = {} unless core['pack']
result = {}
Rails.root.glob('app/javascript/flavours/*/theme.yml') do |pathname| Rails.root.glob('app/javascript/flavours/*/theme.yml') do |pathname|
data = YAML.load_file(pathname) data = YAML.load_file(pathname)
next unless data['pack'] next unless data['pack_directory']
dir = pathname.dirname dir = pathname.dirname
name = dir.basename.to_s name = dir.basename.to_s
@ -38,45 +36,34 @@ class Themes
data['name'] = name data['name'] = name
data['locales'] = locales data['locales'] = locales
data['screenshot'] = screenshots data['screenshot'] = screenshots
data['skin'] = { 'default' => [] } data['skins'] = []
result[name] = data @flavours[name] = data
end end
Rails.root.glob('app/javascript/skins/*/*') do |pathname| Rails.root.glob('app/javascript/skins/*/*') do |pathname|
ext = pathname.extname.to_s ext = pathname.extname.to_s
skin = pathname.basename.to_s skin = pathname.basename.to_s
name = pathname.dirname.basename.to_s name = pathname.dirname.basename.to_s
next unless result[name] next unless @flavours[name]
if pathname.directory? if pathname.directory?
pack = [] @flavours[name]['skins'] << skin if pathname.glob('{common,index,application}.{css,scss}').any?
pathname.glob('*.{css,scss}') do |sheet|
pack.push(sheet.basename(sheet.extname).to_s)
end
elsif /^\.s?css$/i.match?(ext) elsif /^\.s?css$/i.match?(ext)
skin = pathname.basename(ext).to_s @flavours[name]['skins'] << pathname.basename(ext).to_s
pack = ['common']
end end
result[name]['skin'][skin] = pack if skin != 'default'
end end
@core = core
@conf = result
end end
attr_reader :core
def flavour(name) def flavour(name)
@conf[name] @flavours[name]
end end
def flavours def flavours
@conf.keys @flavours.keys
end end
def skins_for(name) def skins_for(name)
@conf[name]['skin'].keys @flavours[name]['skins']
end end
def flavours_and_skins def flavours_and_skins

View file

@ -1,6 +1,8 @@
- content_for :page_title do - content_for :page_title do
= t('auth.login') = t('auth.login')
= flavoured_javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous'
- if webauthn_enabled? - if webauthn_enabled?
= render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' } = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }

View file

@ -1,6 +1,8 @@
- content_for :page_title do - content_for :page_title do
= t('auth.setup.title') = t('auth.setup.title')
= flavoured_javascript_pack_tag 'sign_up', crossorigin: 'anonymous'
= simple_form_for(@user, url: auth_setup_path) do |f| = simple_form_for(@user, url: auth_setup_path) do |f|
= render 'auth/shared/progress', stage: 'confirm' = render 'auth/shared/progress', stage: 'confirm'

View file

@ -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' }/

View file

@ -1,5 +1,7 @@
- content_for :header_tags do - content_for :header_tags do
= render_initial_state = render_initial_state
= flavoured_javascript_pack_tag 'public', crossorigin: 'anonymous'
= flavoured_javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :content do - content_for :content do
.admin-wrapper .admin-wrapper

View file

@ -26,27 +26,21 @@
%title= html_title %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 <style> tag, with this `id` -# Needed for the wicg-inert polyfill. It needs to be on it's own <style> tag, with this `id`
= stylesheet_pack_tag 'core/inert', media: 'all', id: 'inert-style' = flavoured_stylesheet_pack_tag 'inert', media: 'all', id: 'inert-style'
- if @theme = javascript_pack_tag 'common', crossorigin: 'anonymous'
- if @theme[:supported_locales].include? I18n.locale.to_s = preload_locale_pack
= preload_pack_asset "locales/#{@theme[:flavour]}/#{I18n.locale}-json.js"
- elsif @theme[:supported_locales].include? 'en'
= preload_pack_asset "locales/#{@theme[:flavour]}/en-json.js"
= csrf_meta_tags unless skip_csrf_meta_tags? = csrf_meta_tags unless skip_csrf_meta_tags?
%meta{ name: 'style-nonce', content: request.content_security_policy_nonce } %meta{ name: 'style-nonce', content: request.content_security_policy_nonce }
= yield :header_tags
-# These must come after :header_tags to ensure our initial state has been defined.
= render partial: 'layouts/theme', object: @core
= render partial: 'layouts/theme', object: @theme
= stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all' = stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all'
= yield :header_tags
%body{ class: body_classes } %body{ class: body_classes }
= content_for?(:content) ? yield(:content) : yield = content_for?(:content) ? yield(:content) : yield

View file

@ -1,3 +1,6 @@
- content_for :header_tags do
= flavoured_javascript_pack_tag 'public', crossorigin: 'anonymous'
- content_for :content do - content_for :content do
.container-alt .container-alt
.logo-container .logo-container

View file

@ -11,15 +11,12 @@
- if storage_host? - if storage_host?
%link{ rel: 'dns-prefetch', href: storage_host }/ %link{ rel: 'dns-prefetch', href: storage_host }/
= 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'
= javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
= preload_locale_pack
= render_initial_state = render_initial_state
= javascript_pack_tag 'common', crossorigin: 'anonymous' = flavoured_javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
- if @theme
- if @theme[:supported_locales].include? I18n.locale.to_s
= preload_pack_asset "locales/#{@theme[:flavour]}/#{I18n.locale}-json.js"
- elsif @theme[:supported_locales].include? 'en'
= preload_pack_asset "locales/#{@theme[:flavour]}/en-json.js"
= render partial: 'layouts/theme', object: @core
= render partial: 'layouts/theme', object: @theme
%body.embed %body.embed
= yield = yield

View file

@ -5,9 +5,10 @@
%meta{ charset: 'utf-8' }/ %meta{ charset: 'utf-8' }/
%title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ') %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ')
%meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/ %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/
= 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'
= javascript_pack_tag 'common', crossorigin: 'anonymous' = javascript_pack_tag 'common', crossorigin: 'anonymous'
= render partial: 'layouts/theme', object: @core || { pack: 'common' } = flavoured_javascript_pack_tag 'error', crossorigin: 'anonymous'
= render partial: 'layouts/theme', object: @theme || { pack: 'error', flavour: 'glitch', common: { pack: 'common', flavour: 'glitch', skin: 'default' } }
%body.error %body.error
.dialog .dialog
.dialog__illustration .dialog__illustration

View file

@ -18,7 +18,7 @@
<o:PixelsPerInch>96</o:PixelsPerInch> <o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings> </o:OfficeDocumentSettings>
</xml> </xml>
= stylesheet_pack_tag 'core/mailer' = stylesheet_pack_tag "flavours/#{Setting.default_settings['flavour'] || 'glitch'}/mailer"
%body %body
.email{ dir: locale_direction } .email{ dir: locale_direction }
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } %table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }

View file

@ -1,3 +1,6 @@
- content_for :header_tags do
= flavoured_javascript_pack_tag 'public', crossorigin: 'anonymous'
- content_for :content do - content_for :content do
- if user_signed_in? && !@hide_header - if user_signed_in? && !@hide_header
.account-header .account-header

View file

@ -1,13 +1,6 @@
- content_for :header_tags do - content_for :header_tags do
= render_initial_state = render_initial_state
= javascript_pack_tag 'common', crossorigin: 'anonymous' = flavoured_javascript_pack_tag 'public', crossorigin: 'anonymous'
- if @theme
- if @theme[:supported_locales].include? I18n.locale.to_s
= preload_pack_asset "locales/#{@theme[:flavour]}/#{I18n.locale}-json.js"
- elsif @theme[:supported_locales].include? 'en'
= preload_pack_asset "locales/#{@theme[:flavour]}/en-json.js"
= render partial: 'layouts/theme', object: @core
= render partial: 'layouts/theme', object: @theme
:ruby :ruby
meta = @media_attachment.file.meta || {} meta = @media_attachment.file.meta || {}

View file

@ -1,6 +1,9 @@
- content_for :page_title do - content_for :page_title do
= t('settings.relationships') = t('settings.relationships')
- content_for :header_tags do
= flavoured_javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
.filters .filters
.filter-subset .filter-subset
%strong= t 'relationships.relationship' %strong= t 'relationships.relationship'

View file

@ -1,4 +1,4 @@
- content_for :header_tags do - content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/ %meta{ name: 'robots', content: 'noindex' }/
= javascript_pack_tag 'core/remote_interaction_helper', crossorigin: 'anonymous' = flavoured_javascript_pack_tag 'remote_interaction_helper', crossorigin: 'anonymous'

View file

@ -12,3 +12,5 @@
.actions .actions
= f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
= flavoured_javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous'

View file

@ -1,10 +1,12 @@
- content_for :header_tags do - content_for :header_tags do
- if user_signed_in? - if user_signed_in?
= preload_signed_in_js_packs
%meta{ name: 'initialPath', content: request.path } %meta{ name: 'initialPath', content: request.path }
%meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key } %meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key }
= render_initial_state = render_initial_state
= flavoured_javascript_pack_tag 'application', crossorigin: 'anonymous'
.notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } } .notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } }
%noscript %noscript

View file

@ -1,4 +1,5 @@
- content_for :header_tags do - content_for :header_tags do
= render_initial_state = render_initial_state
= flavoured_javascript_pack_tag 'share', crossorigin: 'anonymous'
#mastodon-compose{ data: { props: Oj.dump(default_props) } } #mastodon-compose{ data: { props: Oj.dump(default_props) } }

View file

@ -1,7 +1,7 @@
// Common configuration for webpacker loaded from config/webpacker.yml // Common configuration for webpacker loaded from config/webpacker.yml
const { lstatSync, readFileSync } = require('fs'); const { lstatSync, readFileSync } = require('fs');
const { basename, dirname, extname, join, resolve } = require('path'); const { basename, dirname, join, resolve } = require('path');
const { env } = require('process'); const { env } = require('process');
const glob = require('glob'); const glob = require('glob');
@ -13,28 +13,16 @@ const flavourFiles = glob.sync('app/javascript/flavours/*/theme.yml');
const skinFiles = glob.sync('app/javascript/skins/*/*'); const skinFiles = glob.sync('app/javascript/skins/*/*');
const flavours = {}; const flavours = {};
const core = function () {
const coreFile = resolve('app', 'javascript', 'core', 'theme.yml');
const data = load(readFileSync(coreFile), 'utf8');
if (!data.pack_directory) {
data.pack_directory = dirname(coreFile);
}
return data.pack ? data : {};
}();
flavourFiles.forEach((flavourFile) => { flavourFiles.forEach((flavourFile) => {
const data = load(readFileSync(flavourFile), 'utf8'); const { locales, inherit_locales, pack_directory } = load(readFileSync(flavourFile), 'utf8');
data.name = basename(dirname(flavourFile));
data.skin = {}; flavours[basename(dirname(flavourFile))] = {
if (!data.pack_directory) { name: basename(dirname(flavourFile)),
data.pack_directory = dirname(flavourFile); locales: locales ? join(dirname(flavourFile), locales) : null,
} inherit_locales,
if (data.locales) { pack_directory: pack_directory,
data.locales = join(dirname(flavourFile), data.locales); skin: {},
} };
if (data.pack && typeof data.pack === 'object') {
flavours[data.name] = data;
}
}); });
skinFiles.forEach((skinFile) => { skinFiles.forEach((skinFile) => {
@ -46,12 +34,13 @@ skinFiles.forEach((skinFile) => {
const data = flavours[name].skin; const data = flavours[name].skin;
if (lstatSync(skinFile).isDirectory()) { if (lstatSync(skinFile).isDirectory()) {
data[skin] = {}; data[skin] = {};
const skinPacks = glob.sync(join(skinFile, '*.{css,scss}')); // TODO: more cleanly take the first match
const skinPacks = glob.sync(join(skinFile, '{common,index,application}.{css,scss}'));
skinPacks.forEach((pack) => { skinPacks.forEach((pack) => {
data[skin][basename(pack, extname(pack))] = pack; data[skin] = pack;
}); });
} else if ((skin = skin.match(/^(.*)\.s?css$/i))) { } else if ((skin = skin.match(/^(.*)\.s?css$/i))) {
data[skin[1]] = { common: skinFile }; data[skin[1]] = skinFile;
} }
}); });
@ -62,7 +51,6 @@ const output = {
module.exports = { module.exports = {
settings, settings,
core,
flavours, flavours,
env: { env: {
NODE_ENV: env.NODE_ENV, NODE_ENV: env.NODE_ENV,

View file

@ -1,55 +1,39 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect // Note: You must restart bin/webpack-dev-server for changes to take effect
const { resolve } = require('path'); const { basename, dirname, join, relative, resolve } = require('path');
const CircularDependencyPlugin = require('circular-dependency-plugin'); const CircularDependencyPlugin = require('circular-dependency-plugin');
const { sync } = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const extname = require('path-complete-extname');
const webpack = require('webpack'); const webpack = require('webpack');
const AssetsManifestPlugin = require('webpack-assets-manifest'); const AssetsManifestPlugin = require('webpack-assets-manifest');
const { env, settings, core, flavours, output } = require('./configuration'); const { env, settings, flavours, output } = require('./configuration');
const rules = require('./rules'); const rules = require('./rules');
function reducePacks (data, into = {}) { const extensionGlob = `**/*{${settings.extensions.join(',')}}*`;
if (!data.pack) return into;
for (const entry in data.pack) { function reduceFlavourPacks(data, into = {}) {
const pack = data.pack[entry]; const packPaths = sync(join(data.pack_directory, extensionGlob));
if (!pack) continue;
let packFiles = []; packPaths.forEach((entry) => {
if (typeof pack === 'string') const namespace = relative(join(data.pack_directory), dirname(entry));
packFiles = [pack]; into[`flavours/${data.name}/${join(namespace, basename(entry, extname(entry)))}`] = resolve(entry);
else if (Array.isArray(pack)) });
packFiles = pack;
else
packFiles = [pack.filename];
if (packFiles) {
into[data.name ? `flavours/${data.name}/${entry}` : `core/${entry}`] = packFiles.map(packFile => resolve(data.pack_directory, packFile));
}
}
if (!data.name) return into;
for (const skinName in data.skin) { for (const skinName in data.skin) {
const skin = data.skin[skinName]; const skin = data.skin[skinName];
if (!skin) continue; if (!skin) continue;
for (const entry in skin) { into[`skins/${data.name}/${skinName}`] = resolve(skin);
const packFile = skin[entry];
if (!packFile) continue;
into[`skins/${data.name}/${skinName}/${entry}`] = resolve(packFile);
}
} }
return into; return into;
} }
const entries = Object.assign( const entries = Object.assign(
reducePacks(core), Object.values(flavours).reduce((map, data) => reduceFlavourPacks(data, map), {}),
Object.values(flavours).reduce((map, data) => reducePacks(data, map), {}),
); );

View file

@ -8,6 +8,14 @@ namespace :assets do
def current_user def current_user
nil nil
end end
def current_flavour
Setting.default_settings['flavour']
end
def current_skin
Setting.default_settings['skin']
end
end end
html = renderer.render(action, opts) html = renderer.render(action, opts)

View file

@ -4,7 +4,7 @@ require 'rails_helper'
describe 'statuses/show.html.haml', :without_verify_partial_doubles do describe 'statuses/show.html.haml', :without_verify_partial_doubles do
before do before do
allow(view).to receive_messages(api_oembed_url: '', site_title: 'example site', site_hostname: 'example.com', full_asset_url: '//asset.host/image.svg', current_account: nil, single_user_mode?: false) allow(view).to receive_messages(api_oembed_url: '', site_title: 'example site', site_hostname: 'example.com', full_asset_url: '//asset.host/image.svg', current_flavour: 'glitch', current_account: nil, single_user_mode?: false)
allow(view).to receive(:local_time) allow(view).to receive(:local_time)
allow(view).to receive(:local_time_ago) allow(view).to receive(:local_time_ago)
assign(:instance_presenter, InstancePresenter.new) assign(:instance_presenter, InstancePresenter.new)