discourse/app/helpers/application_helper.rb
Osama Sayegh 787d512c03
FIX: Add theme-color <meta> tag when a dark scheme is selected (#18747)
Meta topic: https://meta.discourse.org/t/meta-theme-color-is-not-respecting-current-color-scheme/239815/7?u=osama.

This commit renders an additional `theme-color` `<meta>` tag for the dark scheme if the current user/request has a scheme selected for dark mode. We currently only render one `theme-color` tag which is always based on the user's selected scheme for light mode, but if the user also selects a scheme for dark mode and uses a device that's configured to use/prefer dark mode, the Discourse UI will be in dark mode, but any parts of the browser/OS UI that's colored based on the `theme-color` tag, would use a color from the user's selected light scheme and look inconsistent with the Discourse UI because the `theme-color` tag is based on the user's selected light scheme.

The additional `theme-color` tag has `media="(prefers-color-scheme: dark)"` and is based on the user's selected dark scheme which means any browser UI that's colored based on `theme-color` tags should be able to pick the right tag based on the user's preference for light/dark mode.
2022-10-26 07:18:05 +03:00

733 lines
21 KiB
Ruby

# coding: utf-8
# frozen_string_literal: true
require 'current_user'
require 'canonical_url'
module ApplicationHelper
include CurrentUser
include CanonicalURL::Helpers
include ConfigurableUrls
include GlobalPath
def self.extra_body_classes
@extra_body_classes ||= Set.new
end
def discourse_config_environment(testing: false)
# TODO: Can this come from Ember CLI somehow?
config = {
modulePrefix: "discourse",
environment: Rails.env,
rootURL: Discourse.base_path,
locationType: "history",
historySupportMiddleware: false,
EmberENV: {
FEATURES: {},
EXTEND_PROTOTYPES: { "Date": false },
_APPLICATION_TEMPLATE_WRAPPER: false,
_DEFAULT_ASYNC_OBSERVERS: true,
_JQUERY_INTEGRATION: true
},
APP: {
name: "discourse",
version: "#{Discourse::VERSION::STRING} #{Discourse.git_version}",
exportApplicationGlobal: true
}
}
if testing
config[:environment] = "test"
config[:locationType] = "none"
config[:APP][:autoboot] = false
config[:APP][:rootElement] = '#ember-testing'
end
config.to_json
end
def google_universal_analytics_json(ua_domain_name = nil)
result = {}
if ua_domain_name
result[:cookieDomain] = ua_domain_name.gsub(/^http(s)?:\/\//, '')
end
if current_user.present?
result[:userId] = current_user.id
end
if SiteSetting.ga_universal_auto_link_domains.present?
result[:allowLinker] = true
end
result.to_json
end
def ga_universal_json
google_universal_analytics_json(SiteSetting.ga_universal_domain_name)
end
def google_tag_manager_json
google_universal_analytics_json
end
def self.google_tag_manager_nonce
@gtm_nonce ||= SecureRandom.hex
end
def shared_session_key
if SiteSetting.long_polling_base_url != '/' && current_user
sk = "shared_session_key"
return request.env[sk] if request.env[sk]
request.env[sk] = key = (session[sk] ||= SecureRandom.hex)
Discourse.redis.setex "#{sk}_#{key}", 7.days, current_user.id.to_s
key
end
end
def is_brotli_req?
request.env["HTTP_ACCEPT_ENCODING"] =~ /br/
end
def is_gzip_req?
request.env["HTTP_ACCEPT_ENCODING"] =~ /gzip/
end
def script_asset_path(script)
path = ActionController::Base.helpers.asset_path("#{script}.js")
if GlobalSetting.use_s3? && GlobalSetting.s3_cdn_url
if GlobalSetting.cdn_url
folder = ActionController::Base.config.relative_url_root || "/"
path = path.gsub(File.join(GlobalSetting.cdn_url, folder, "/"), File.join(GlobalSetting.s3_cdn_url, "/"))
else
# we must remove the subfolder path here, assets are uploaded to s3
# without it getting involved
if ActionController::Base.config.relative_url_root
path = path.sub(ActionController::Base.config.relative_url_root, "")
end
path = "#{GlobalSetting.s3_cdn_url}#{path}"
end
# assets needed for theme testing are not compressed because they take a fair
# amount of time to compress (+30 seconds) during rebuilds/deploys when the
# vast majority of sites will never need them, so it makes more sense to serve
# them uncompressed instead of making everyone's rebuild/deploy take +30 more
# seconds.
if !script.start_with?("discourse/tests/")
if is_brotli_req?
path = path.gsub(/\.([^.]+)$/, '.br.\1')
elsif is_gzip_req?
path = path.gsub(/\.([^.]+)$/, '.gz.\1')
end
end
elsif GlobalSetting.cdn_url&.start_with?("https") && is_brotli_req? && Rails.env != "development"
path = path.gsub("#{GlobalSetting.cdn_url}/assets/", "#{GlobalSetting.cdn_url}/brotli_asset/")
end
path
end
def preload_script(script)
scripts = [script]
if chunks = EmberCli.script_chunks[script]
scripts.push(*chunks)
end
scripts.map do |name|
path = script_asset_path(name)
preload_script_url(path)
end.join("\n").html_safe
end
def preload_script_url(url)
add_resource_preload_list(url, 'script')
if GlobalSetting.preload_link_header
<<~HTML.html_safe
<script defer src="#{url}"></script>
HTML
else
<<~HTML.html_safe
<link rel="preload" href="#{url}" as="script">
<script defer src="#{url}"></script>
HTML
end
end
def add_resource_preload_list(resource_url, type)
@links_to_preload << %Q(<#{resource_url}>; rel="preload"; as="#{type}") if !@links_to_preload.nil?
end
def discourse_csrf_tags
# anon can not have a CSRF token cause these are all pages
# that may be cached, causing a mismatch between session CSRF
# and CSRF on page and horrible impossible to debug login issues
if current_user
csrf_meta_tags
end
end
def html_classes
list = []
list << (mobile_view? ? 'mobile-view' : 'desktop-view')
list << (mobile_device? ? 'mobile-device' : 'not-mobile-device')
list << 'ios-device' if ios_device?
list << 'rtl' if rtl?
list << text_size_class
list << 'anon' unless current_user
list.join(' ')
end
def body_classes
result = ApplicationHelper.extra_body_classes.to_a
if @category && @category.url.present?
result << "category-#{@category.slug_path.join('-')}"
end
if current_user.present? &&
current_user.primary_group_id &&
primary_group_name = Group.where(id: current_user.primary_group_id).pluck_first(:name)
result << "primary-group-#{primary_group_name.downcase}"
end
result.join(' ')
end
def text_size_class
requested_cookie_size, cookie_seq = cookies[:text_size]&.split("|")
server_seq = current_user&.user_option&.text_size_seq
if cookie_seq && server_seq && cookie_seq.to_i >= server_seq &&
UserOption.text_sizes.keys.include?(requested_cookie_size&.to_sym)
cookie_size = requested_cookie_size
end
size = cookie_size || current_user&.user_option&.text_size || SiteSetting.default_text_size
"text-size-#{size}"
end
def escape_unicode(javascript)
if javascript
javascript = javascript.scrub
javascript.gsub!(/\342\200\250/u, '&#x2028;')
javascript.gsub!(/(<\/)/u, '\u003C/')
javascript
else
''
end
end
def format_topic_title(title)
PrettyText.unescape_emoji strip_tags(title)
end
def with_format(format, &block)
old_formats = formats
self.formats = [format]
block.call
self.formats = old_formats
nil
end
def age_words(secs)
AgeWords.age_words(secs)
end
def short_date(dt)
if dt.year == Time.now.year
I18n.l(dt, format: :short_no_year)
else
I18n.l(dt, format: :date_only)
end
end
def guardian
@guardian ||= Guardian.new(current_user)
end
def admin?
current_user.try(:admin?)
end
def moderator?
current_user.try(:moderator?)
end
def staff?
current_user.try(:staff?)
end
def rtl?
["ar", "ur", "fa_IR", "he"].include? I18n.locale.to_s
end
def html_lang
(request ? I18n.locale.to_s : SiteSetting.default_locale).sub("_", "-")
end
# Creates open graph and twitter card meta data
def crawlable_meta_data(opts = nil)
opts ||= {}
opts[:url] ||= "#{Discourse.base_url_no_prefix}#{request.fullpath}"
# if slug generation method is encoded, non encoded urls can sneak in
# via bots
url = opts[:url]
if url.encoding.name != "UTF-8" || !url.valid_encoding?
opts[:url] = url.dup.force_encoding("UTF-8").scrub!
end
if opts[:image].blank?
twitter_summary_large_image_url = SiteSetting.site_twitter_summary_large_image_url
if twitter_summary_large_image_url.present?
opts[:twitter_summary_large_image] = twitter_summary_large_image_url
end
opts[:image] = SiteSetting.site_opengraph_image_url
end
# Use the correct scheme for opengraph/twitter image
opts[:image] = get_absolute_image_url(opts[:image]) if opts[:image].present?
opts[:twitter_summary_large_image] =
get_absolute_image_url(opts[:twitter_summary_large_image]) if opts[:twitter_summary_large_image].present?
result = []
result << tag(:meta, property: 'og:site_name', content: SiteSetting.title)
result << tag(:meta, property: 'og:type', content: 'website')
generate_twitter_card_metadata(result, opts)
result << tag(:meta, property: "og:image", content: opts[:image]) if opts[:image].present?
[:url, :title, :description].each do |property|
if opts[property].present?
content = (property == :url ? opts[property] : gsub_emoji_to_unicode(opts[property]))
result << tag(:meta, { property: "og:#{property}", content: content }, nil, true)
result << tag(:meta, { name: "twitter:#{property}", content: content }, nil, true)
end
end
if opts[:read_time] && opts[:read_time] > 0 && opts[:like_count] && opts[:like_count] > 0
result << tag(:meta, name: 'twitter:label1', value: I18n.t("reading_time"))
result << tag(:meta, name: 'twitter:data1', value: "#{opts[:read_time]} mins 🕑")
result << tag(:meta, name: 'twitter:label2', value: I18n.t("likes"))
result << tag(:meta, name: 'twitter:data2', value: "#{opts[:like_count]}")
end
if opts[:published_time]
result << tag(:meta, property: 'article:published_time', content: opts[:published_time])
end
if opts[:ignore_canonical]
result << tag(:meta, property: 'og:ignore_canonical', content: true)
end
result.join("\n")
end
private def generate_twitter_card_metadata(result, opts)
img_url = opts[:twitter_summary_large_image].present? ? \
opts[:twitter_summary_large_image] :
opts[:image]
# Twitter does not allow SVGs, see https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup
if img_url.ends_with?(".svg")
img_url = SiteSetting.site_logo_url.ends_with?(".svg") ? nil : SiteSetting.site_logo_url
end
if opts[:twitter_summary_large_image].present? && img_url.present?
result << tag(:meta, name: 'twitter:card', content: "summary_large_image")
result << tag(:meta, name: "twitter:image", content: img_url)
elsif opts[:image].present? && img_url.present?
result << tag(:meta, name: 'twitter:card', content: "summary")
result << tag(:meta, name: "twitter:image", content: img_url)
else
result << tag(:meta, name: 'twitter:card', content: "summary")
end
end
def render_sitelinks_search_tag
if current_page?('/') || current_page?(Discourse.base_path)
json = {
'@context' => 'http://schema.org',
'@type' => 'WebSite',
url: Discourse.base_url,
potentialAction: {
'@type' => 'SearchAction',
target: "#{Discourse.base_url}/search?q={search_term_string}",
'query-input' => 'required name=search_term_string',
}
}
content_tag(:script, MultiJson.dump(json).html_safe, type: 'application/ld+json')
end
end
def gsub_emoji_to_unicode(str)
Emoji.gsub_emoji_to_unicode(str)
end
def application_logo_url
@application_logo_url ||= begin
if mobile_view?
if dark_color_scheme? && SiteSetting.site_mobile_logo_dark_url.present?
SiteSetting.site_mobile_logo_dark_url
elsif SiteSetting.site_mobile_logo_url.present?
SiteSetting.site_mobile_logo_url
end
else
if dark_color_scheme? && SiteSetting.site_logo_dark_url.present?
SiteSetting.site_logo_dark_url
else
SiteSetting.site_logo_url
end
end
end
end
def application_logo_dark_url
@application_logo_dark_url ||= begin
if dark_scheme_id != -1
if mobile_view? && SiteSetting.site_mobile_logo_dark_url != application_logo_url
SiteSetting.site_mobile_logo_dark_url
elsif !mobile_view? && SiteSetting.site_logo_dark_url != application_logo_url
SiteSetting.site_logo_dark_url
end
end
end
end
def login_path
"#{Discourse.base_path}/login"
end
def mobile_view?
MobileDetection.resolve_mobile_view!(request.user_agent, params, session)
end
def crawler_layout?
controller&.use_crawler_layout?
end
def include_crawler_content?
crawler_layout? || !mobile_view? || !modern_mobile_device?
end
def modern_mobile_device?
MobileDetection.modern_mobile_device?(request.user_agent)
end
def mobile_device?
MobileDetection.mobile_device?(request.user_agent)
end
def ios_device?
MobileDetection.ios_device?(request.user_agent)
end
def customization_disabled?
request.env[ApplicationController::NO_THEMES]
end
def include_ios_native_app_banner?
current_user && current_user.trust_level >= 1 && SiteSetting.native_app_install_banner_ios
end
def ios_app_argument
# argument only makes sense for DiscourseHub app
SiteSetting.ios_app_id == "1173672076" ?
", app-argument=discourse://new?siteUrl=#{Discourse.base_url}" : ""
end
def include_splash_screen?
# A bit basic for now but will be expanded later
SiteSetting.splash_screen
end
def allow_plugins?
!request.env[ApplicationController::NO_PLUGINS]
end
def allow_third_party_plugins?
allow_plugins? && !request.env[ApplicationController::NO_UNOFFICIAL_PLUGINS]
end
def normalized_safe_mode
safe_mode = []
safe_mode << ApplicationController::NO_THEMES if customization_disabled?
safe_mode << ApplicationController::NO_PLUGINS if !allow_plugins?
safe_mode << ApplicationController::NO_UNOFFICIAL_PLUGINS if !allow_third_party_plugins?
safe_mode.join(",")
end
def loading_admin?
return false unless defined?(controller)
return false if controller.class.name.blank?
controller.class.name.split("::").first == "Admin"
end
def category_badge(category, opts = nil)
CategoryBadge.html_for(category, opts).html_safe
end
def self.all_connectors
@all_connectors = Dir.glob("plugins/*/app/views/connectors/**/*.html.erb")
end
def server_plugin_outlet(name, locals: {})
return "" if !GlobalSetting.load_plugins?
matcher = Regexp.new("/connectors/#{name}/.*\.html\.erb$")
erbs = ApplicationHelper.all_connectors.select { |c| c =~ matcher }
return "" if erbs.blank?
result = +""
erbs.each { |erb| result << render(inline: File.read(erb), locals: locals) }
result.html_safe
end
def topic_featured_link_domain(link)
begin
uri = UrlHelper.encode_and_parse(link)
uri = URI.parse("http://#{uri}") if uri.scheme.nil?
host = uri.host.downcase
host.start_with?('www.') ? host[4..-1] : host
rescue
''
end
end
def theme_id
if customization_disabled?
nil
else
request.env[:resolved_theme_id]
end
end
def stylesheet_manager
return @stylesheet_manager if defined?(@stylesheet_manager)
@stylesheet_manager = Stylesheet::Manager.new(theme_id: theme_id)
end
def scheme_id
return @scheme_id if defined?(@scheme_id)
custom_user_scheme_id = cookies[:color_scheme_id] || current_user&.user_option&.color_scheme_id
if custom_user_scheme_id && ColorScheme.find_by_id(custom_user_scheme_id)
return custom_user_scheme_id
end
return if theme_id.blank?
@scheme_id = Theme.where(id: theme_id).pluck_first(:color_scheme_id)
end
def dark_scheme_id
cookies[:dark_scheme_id] || current_user&.user_option&.dark_scheme_id || SiteSetting.default_dark_mode_color_scheme_id
end
def current_homepage
current_user&.user_option&.homepage || SiteSetting.anonymous_homepage
end
def build_plugin_html(name)
return "" unless allow_plugins?
DiscoursePluginRegistry.build_html(name, controller) || ""
end
# If there is plugin HTML return that, otherwise yield to the template
def replace_plugin_html(name)
if (html = build_plugin_html(name)).present?
html
else
yield
nil
end
end
def theme_lookup(name)
Theme.lookup_field(
theme_id,
mobile_view? ? :mobile : :desktop,
name,
skip_transformation: request.env[:skip_theme_ids_transformation].present?
)
end
def theme_translations_lookup
Theme.lookup_field(
theme_id,
:translations,
I18n.locale,
skip_transformation: request.env[:skip_theme_ids_transformation].present?
)
end
def theme_js_lookup
Theme.lookup_field(
theme_id,
:extra_js,
nil,
skip_transformation: request.env[:skip_theme_ids_transformation].present?
)
end
def discourse_stylesheet_preload_tag(name, opts = {})
manager =
if opts.key?(:theme_id)
Stylesheet::Manager.new(
theme_id: customization_disabled? ? nil : opts[:theme_id]
)
else
stylesheet_manager
end
manager.stylesheet_preload_tag(name, 'all')
end
def discourse_stylesheet_link_tag(name, opts = {})
manager =
if opts.key?(:theme_id)
Stylesheet::Manager.new(
theme_id: customization_disabled? ? nil : opts[:theme_id]
)
else
stylesheet_manager
end
manager.stylesheet_link_tag(name, 'all', self.method(:add_resource_preload_list))
end
def discourse_preload_color_scheme_stylesheets
result = +""
result << stylesheet_manager.color_scheme_stylesheet_preload_tag(scheme_id, 'all')
if dark_scheme_id != -1
result << stylesheet_manager.color_scheme_stylesheet_preload_tag(dark_scheme_id, '(prefers-color-scheme: dark)')
end
result.html_safe
end
def discourse_color_scheme_stylesheets
result = +""
result << stylesheet_manager.color_scheme_stylesheet_link_tag(scheme_id, 'all', self.method(:add_resource_preload_list))
if dark_scheme_id != -1
result << stylesheet_manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)', self.method(:add_resource_preload_list))
end
result.html_safe
end
def discourse_theme_color_meta_tags
result = +<<~HTML
<meta name="theme-color" media="all" content="##{ColorScheme.hex_for_name('header_background', scheme_id)}">
HTML
if dark_scheme_id != -1
result << <<~HTML
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="##{ColorScheme.hex_for_name('header_background', dark_scheme_id)}">
HTML
end
result.html_safe
end
def dark_color_scheme?
return false if scheme_id.blank?
ColorScheme.find_by_id(scheme_id)&.is_dark?
end
def preloaded_json
return '{}' if @preloaded.blank?
@preloaded.transform_values { |value| escape_unicode(value) }.to_json
end
def client_side_setup_data
setup_data = {
cdn: Rails.configuration.action_controller.asset_host,
base_url: Discourse.base_url,
base_uri: Discourse.base_path,
environment: Rails.env,
letter_avatar_version: LetterAvatar.version,
markdown_it_url: script_asset_path('markdown-it-bundle'),
service_worker_url: 'service-worker.js',
default_locale: SiteSetting.default_locale,
asset_version: Discourse.assets_digest,
disable_custom_css: loading_admin?,
highlight_js_path: HighlightJs.path,
svg_sprite_path: SvgSprite.path(theme_id),
enable_js_error_reporting: GlobalSetting.enable_js_error_reporting,
color_scheme_is_dark: dark_color_scheme?,
user_color_scheme_id: scheme_id,
user_dark_scheme_id: dark_scheme_id
}
if Rails.env.development?
setup_data[:svg_icon_list] = SvgSprite.all_icons(theme_id)
if ENV['DEBUG_PRELOADED_APP_DATA']
setup_data[:debug_preloaded_app_data] = true
end
setup_data[:mb_last_file_change_id] = MessageBus.last_id('/file-change')
end
if guardian.can_enable_safe_mode? && params["safe_mode"]
setup_data[:safe_mode] = normalized_safe_mode
end
if SiteSetting.Upload.enable_s3_uploads
setup_data[:s3_cdn] = SiteSetting.Upload.s3_cdn_url.presence
setup_data[:s3_base_url] = SiteSetting.Upload.s3_base_url
end
setup_data
end
def get_absolute_image_url(link)
absolute_url = link
if link.start_with?('//')
uri = URI(Discourse.base_url)
absolute_url = "#{uri.scheme}:#{link}"
elsif link.start_with?('/uploads/', '/images/', '/user_avatar/')
absolute_url = "#{Discourse.base_url}#{link}"
elsif GlobalSetting.relative_url_root && link.start_with?(GlobalSetting.relative_url_root)
absolute_url = "#{Discourse.base_url_no_prefix}#{link}"
end
absolute_url
end
def manifest_url
# If you want the `manifest_url` to be different for a specific action,
# in the action set @manifest_url = X. Originally added for chat to add a
# separate manifest
@manifest_url || "#{Discourse.base_path}/manifest.webmanifest"
end
def can_sign_up?
SiteSetting.allow_new_registrations &&
!SiteSetting.invite_only &&
!SiteSetting.enable_discourse_connect
end
def rss_creator(user)
user&.display_name
end
def authentication_data
return @authentication_data if defined?(@authentication_data)
@authentication_data = begin
value = cookies[:authentication_data]
if value
cookies.delete(:authentication_data, path: Discourse.base_path("/"))
end
current_user ? nil : value
end
end
end