0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/app/serializers/site_serializer.rb
Kris d2d79d290e
FEATURE: explicit default homepage setting (#41785)
This creates an explicit "default homepage" setting, because right now
the experience of changing the homepage via the "top menu" setting isn't
very intuitive. This will also make it easier to support new homepage
options in the future that don't necessarily need to exist within "top
menu."

The setting is empty by default, and when empty it falls back to "First
item in top menu" — using the current functionality as a fallback means
we don't need a migration.

The options in the list are the core top menu choices plus anything
registered via `Discourse.filters.push(:filter)`. Registered filters are
an existing pattern that come with all the prerequisites to function as
a homepage (server & client routes, and a topic list).

So for example, how the "votes" option is produced

plugins/discourse-topic-voting/plugin.rb
```ruby
Discourse.filters.push(:votes)
Discourse.anonymous_filters.push(:votes)
```

anonymous_filters is optional, if there's no anonymous equivalent of a
given option, we fall back to the first anon-viewable top menu item
(there's always at least one)

lib/discourse_topic_voting/topic_query_extension.rb
```ruby
def list_votes
  create_list(:votes, unordered: true) do |topics|
    topics.joins(
      "LEFT JOIN topic_voting_topic_vote_count dvtvc ON dvtvc.topic_id = topics.id",
    ).order("COALESCE(dvtvc.votes_count,'0')::integer DESC, topics.bumped_at DESC")
  end
end
```

config/locales/client.en.yml
```yaml
js:
  filters:
    votes:
      title: "Votes"
      help: "topics with the all time most votes"
```

<img width="600" alt="image"
src="https://github.com/user-attachments/assets/6b580e92-1b58-477c-81ab-6ceacc020672"
/>

This also adds a persistent "Default" option to the Default Home Page
user preference, which was previously only visible when a custom
homepage is set by a theme.

A future enhancement could add a plugin API for registering non-filter
homepage options (e.g. a chat channel)

Some related fixes along for the ride:

* select-kit treated a blank value as "clear selection", so it could not
be selected or displayed... this allows "First item in top menu" as an
alias for being unset
* the admin combo-box couldn't detect when the first choice's value was
blank, and submitted the whole object
* topic lists advertised RSS feeds that don't exist for registered
filters (this was happening with /votes)
2026-07-22 16:22:19 -04:00

492 lines
11 KiB
Ruby
Vendored

# frozen_string_literal: true
class SiteSerializer < ApplicationSerializer
include NavigationMenuTagsMixin
attributes(
:default_archetype,
:notification_types,
:post_types,
:user_tips,
:trust_levels,
:groups,
:filters,
:homepage_choices,
:periods,
:top_menu_items,
:anonymous_top_menu_items,
:uncategorized_category_id, # this is hidden so putting it here
:user_field_max_length,
:post_action_types,
:topic_flag_types,
:can_create_tag,
:can_tag_topics,
:can_tag_pms,
:tags_filter_regexp,
:top_tags,
:navigation_menu_site_top_tags,
:can_associate_groups,
:wizard_required,
:topic_featured_link_allowed_category_ids,
:user_themes,
:user_color_schemes,
:default_light_color_scheme,
:default_dark_color_scheme,
:censored_regexp,
:shared_drafts_category_id,
:custom_emoji_translation,
:watched_words_replace,
:watched_words_link,
:categories,
:markdown_additional_options,
:hashtag_configurations,
:hashtag_icons,
:anonymous_default_navigation_menu_tags,
:anonymous_sidebar_sections,
:whispers_allowed_groups_names,
:denied_emojis,
:tos_url,
:privacy_policy_url,
:system_user_avatar_template,
:lazy_load_categories,
:valid_flag_applies_to_types,
:full_name_required_for_signup,
:full_name_visible_in_signup,
:admin_config_login_routes,
:email_configured,
:upcoming_changes_with_css,
:permanent_upcoming_change_names,
:access_control,
:category_types,
)
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
has_many :user_fields, embed: :objects, serializer: UserFieldSerializer
has_many :auth_providers, embed: :objects, serializer: AuthProviderSerializer
has_many :anonymous_sidebar_sections, embed: :objects, serializer: SidebarSectionSerializer
def user_themes
cache_fragment("user_themes") do
themes =
Theme
.where("id = :default OR user_selectable", default: SiteSetting.default_theme_id)
.order("lower(name)")
.pluck(:id, :name, :color_scheme_id, :dark_color_scheme_id)
modifier_theme_ids =
ThemeModifierSet
.where(theme_id: themes.map(&:first), only_theme_color_schemes: true)
.pluck(:theme_id)
.to_set
themes
.map do |id, name, color_scheme_id, dark_color_scheme_id|
{
theme_id: id,
name: name,
default: id == SiteSetting.default_theme_id,
color_scheme_id: color_scheme_id,
dark_color_scheme_id: dark_color_scheme_id,
only_theme_color_schemes: modifier_theme_ids.include?(id),
}
end
.as_json
end
end
def user_color_schemes
cache_fragment("user_color_schemes") do
theme_ids_with_modifier =
ThemeModifierSet.where(only_theme_color_schemes: true).pluck(:theme_id)
schemes =
ColorScheme
.includes(:color_scheme_colors)
.where(user_selectable: true)
.or(ColorScheme.where(theme_id: theme_ids_with_modifier))
.order(:name)
ActiveModel::ArraySerializer.new(
schemes,
each_serializer: ColorSchemeSelectableSerializer,
).as_json
end
end
def default_light_color_scheme
ColorSchemeSerializer.new(
ColorScheme.find_by_id(Theme.find_default&.color_scheme_id),
root: false,
).as_json
end
def default_dark_color_scheme
ColorSchemeSerializer.new(
ColorScheme.find_by_id(Theme.find_default&.dark_color_scheme_id),
root: false,
).as_json
end
def groups
cache_anon_fragment("group_names") do
object
.groups
.order(:name)
.select(
:id,
:name,
:full_name,
:flair_icon,
:flair_upload_id,
:flair_bg_color,
:flair_color,
:automatic,
)
.map do |g|
{
id: g.id,
name: g.name,
full_name: g.full_name.presence || g.name,
display_name: g.full_name.presence || g.name,
flair_url: g.flair_url,
flair_bg_color: g.flair_bg_color,
flair_color: g.flair_color,
automatic: g.automatic,
}
end
.as_json
end
end
def post_action_types
Discourse
.cache
.fetch("post_action_types_#{I18n.locale}") do
if PostActionType.overridden_by_plugin_or_skipped_db?
types = ordered_flags(PostActionType.types.values)
ActiveModel::ArraySerializer.new(types).as_json
else
flags = Flag.unscoped.order(:position).where(score_type: false).all
ActiveModel::ArraySerializer.new(
flags,
each_serializer: FlagSerializer,
target: :post_action,
used_flag_ids: used_flag_ids(flags.map(&:id)),
).as_json
end
end
end
def topic_flag_types
Discourse
.cache
.fetch("post_action_flag_types_#{I18n.locale}") do
if PostActionType.overridden_by_plugin_or_skipped_db?
types = ordered_flags(PostActionType.topic_flag_types.values)
ActiveModel::ArraySerializer.new(types, each_serializer: TopicFlagTypeSerializer).as_json
else
flags =
Flag
.unscoped
.where("'Topic' = ANY(applies_to)")
.where(score_type: false)
.order(:position)
.all
ActiveModel::ArraySerializer.new(
flags,
each_serializer: FlagSerializer,
target: :topic_flag,
used_flag_ids: used_flag_ids(flags.map(&:id)),
).as_json
end
end
end
def default_archetype
Archetype.default
end
def post_types
Post.types
end
def user_tips
User.user_tips
end
def include_user_tips?
SiteSetting.enable_user_tips
end
def filters
Discourse.filters.map(&:to_s)
end
def homepage_choices
TopMenu.homepage_choices
end
def periods
TopTopic.periods.map(&:to_s)
end
def top_menu_items
Discourse.top_menu_items.map(&:to_s)
end
def anonymous_top_menu_items
Discourse.anonymous_top_menu_items.map(&:to_s)
end
def uncategorized_category_id
SiteSetting.uncategorized_category_id
end
def user_field_max_length
UserField.max_length
end
def can_create_tag
scope.can_create_tag?
end
def can_tag_topics
scope.can_tag_topics?
end
def can_tag_pms
scope.can_tag_pms?
end
def can_associate_groups
scope.can_associate_groups?
end
def include_can_associate_groups?
scope.is_admin?
end
def include_tags_filter_regexp?
SiteSetting.tagging_enabled
end
def tags_filter_regexp
DiscourseTagging::TAGS_FILTER_REGEXP.source
end
def include_top_tags?
Tag.include_tags?
end
def top_tags
@top_tags ||= Tag.top_tags(guardian: scope)
end
def wizard_required
true
end
def include_wizard_required?
Wizard.user_requires_completion?(scope.user)
end
def include_topic_featured_link_allowed_category_ids?
SiteSetting.topic_featured_link_enabled
end
def topic_featured_link_allowed_category_ids
scope.topic_featured_link_allowed_category_ids
end
def censored_regexp
WordWatcher.serialized_regexps_for_action(:censor)
end
def custom_emoji_translation
Plugin::CustomEmoji.translations
end
def shared_drafts_category_id
SiteSetting.shared_drafts_category.to_i
end
def include_shared_drafts_category_id?
scope.can_see_shared_draft? && SiteSetting.shared_drafts_enabled?
end
def watched_words_replace
WordWatcher.regexps_for_action(:replace)
end
def watched_words_link
WordWatcher.regexps_for_action(:link)
end
def categories
object.categories.map { |c| c.to_h }
end
def include_categories?
object.categories.present?
end
def markdown_additional_options
Site.markdown_additional_options
end
def hashtag_configurations
HashtagAutocompleteService.contexts_with_ordered_types
end
def hashtag_icons
HashtagAutocompleteService.data_source_icon_map
end
SIDEBAR_TOP_TAGS_TO_SHOW = 5
def navigation_menu_site_top_tags
if top_tags.present?
top_tag_objects = top_tags[0...SIDEBAR_TOP_TAGS_TO_SHOW]
tag_ids = top_tag_objects.map { |t| t[:id] }
serialized = serialize_tags(Tag.where(id: tag_ids))
# Ensures order of top tags is preserved
serialized.sort_by { |tag| tag_ids.index(tag[:id]) }
else
[]
end
end
def include_navigation_menu_site_top_tags?
SiteSetting.tagging_enabled
end
def anonymous_default_navigation_menu_tags
@anonymous_default_navigation_menu_tags ||=
begin
tag_names =
SiteSetting.default_navigation_menu_tags.split("|") -
DiscourseTagging.hidden_tag_names(scope)
serialize_tags(Tag.where(name: tag_names).order(:name))
end
end
def include_anonymous_default_navigation_menu_tags?
scope.anonymous? && SiteSetting.tagging_enabled &&
SiteSetting.default_navigation_menu_tags.present? &&
anonymous_default_navigation_menu_tags.present?
end
def include_anonymous_sidebar_sections?
scope.anonymous?
end
def whispers_allowed_groups_names
Group.where(id: SiteSetting.whispers_allowed_groups_map).pluck(:name)
end
def include_whispers_allowed_groups_names?
scope.can_see_whispers?
end
def denied_emojis
@denied_emojis ||= Emoji.denied
end
def include_denied_emojis?
denied_emojis.present?
end
def tos_url
Discourse.tos_url
end
def include_tos_url?
tos_url.present?
end
def privacy_policy_url
Discourse.privacy_policy_url
end
def include_privacy_policy_url?
privacy_policy_url.present?
end
def system_user_avatar_template
Discourse.system_user.avatar_template
end
def include_system_user_avatar_template?
SiteSetting.show_user_menu_avatars
end
def lazy_load_categories
true
end
def include_lazy_load_categories?
scope.can_lazy_load_categories?
end
def valid_flag_applies_to_types
Flag.valid_applies_to_types
end
def include_valid_flag_applies_to_types?
scope.is_admin?
end
def admin_config_login_routes
DiscoursePluginRegistry.admin_config_login_routes
end
def include_admin_config_login_routes?
scope.is_admin?
end
def email_configured
GlobalSetting.smtp_address.present?
end
def full_name_required_for_signup
Site.full_name_required_for_signup
end
def full_name_visible_in_signup
Site.full_name_visible_in_signup
end
def upcoming_changes_with_css
UpcomingChanges.including_css
end
def permanent_upcoming_change_names
UpcomingChanges.permanent_upcoming_change_names
end
def include_permanent_upcoming_change_names?
scope.is_staff?
end
def category_types
Categories::TypeRegistry.list(only_visible: true, guardian: scope)
end
def include_category_types?
scope.is_staff?
end
private
def ordered_flags(flags)
flags.map { |id| PostActionType.new(id: id) }
end
def used_flag_ids(flag_ids)
@used_flag_ids ||= Flag.used_flag_ids(flag_ids)
end
end