diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js
index 4f38a757e93..eb48510ee16 100644
--- a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js
+++ b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js
@@ -186,6 +186,11 @@ export default Controller.extend({
homeChanged() {
const siteHome = this.siteSettings.top_menu.split("|")[0].split(",")[0];
+
+ if (this.model.canPickThemeWithCustomHomepage) {
+ USER_HOMES[-1] = "custom";
+ }
+
const userHome = USER_HOMES[this.get("model.user_option.homepage_id")];
setDefaultHomepage(userHome || siteHome);
@@ -200,6 +205,14 @@ export default Controller.extend({
});
let result = [];
+
+ if (this.model.canPickThemeWithCustomHomepage) {
+ result.push({
+ name: I18n.t("user.homepage.default"),
+ value: -1,
+ });
+ }
+
this.siteSettings.top_menu.split("|").forEach((m) => {
let id = homeValues[m];
if (id) {
diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js
index de643e6118e..10532624984 100644
--- a/app/assets/javascripts/discourse/app/models/user.js
+++ b/app/assets/javascripts/discourse/app/models/user.js
@@ -209,6 +209,7 @@ export default class User extends RestModel.extend(Evented) {
@alias("sidebar_sections") sidebarSections;
@mapBy("sidebarTags", "name") sidebarTagNames;
@filterBy("groups", "has_messages", true) groupsWithMessages;
+ @alias("can_pick_theme_with_custom_homepage") canPickThemeWithCustomHomepage;
numGroupsToDisplay = 2;
diff --git a/app/assets/javascripts/discourse/app/routes/app-route-map.js b/app/assets/javascripts/discourse/app/routes/app-route-map.js
index 0db907f8b38..77dc9e1d20e 100644
--- a/app/assets/javascripts/discourse/app/routes/app-route-map.js
+++ b/app/assets/javascripts/discourse/app/routes/app-route-map.js
@@ -63,6 +63,8 @@ export default function () {
this.route("categoryNone", { path: "/c/*category_slug_path_with_id/none" });
this.route("categoryAll", { path: "/c/*category_slug_path_with_id/all" });
this.route("category", { path: "/c/*category_slug_path_with_id" });
+
+ this.route("custom");
});
this.route("groups", { resetNamespace: true, path: "/g" }, function () {
diff --git a/app/assets/javascripts/discourse/app/templates/discovery/custom.hbs b/app/assets/javascripts/discourse/app/templates/discovery/custom.hbs
new file mode 100644
index 00000000000..cb2c9b74d9e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/discovery/custom.hbs
@@ -0,0 +1,7 @@
+
+ {{#if this.currentUser.admin}}
+
+ {{i18n "custom_homepage.admin_message"}}
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 0fe7bd1585e..8e489dde11e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -519,7 +519,7 @@ class ApplicationController < ActionController::Base
end
def current_homepage
- current_user&.user_option&.homepage || SiteSetting.anonymous_homepage
+ current_user&.user_option&.homepage || HomepageHelper.resolve(request, current_user)
end
def serialize_data(obj, serializer, opts = nil)
diff --git a/app/controllers/custom_homepage_controller.rb b/app/controllers/custom_homepage_controller.rb
new file mode 100644
index 00000000000..2cf241df82b
--- /dev/null
+++ b/app/controllers/custom_homepage_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class CustomHomepageController < ApplicationController
+ def index
+ render "default/custom"
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index fe0edba2d80..7f07c00db36 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -559,7 +559,7 @@ module ApplicationHelper
end
def current_homepage
- current_user&.user_option&.homepage || SiteSetting.anonymous_homepage
+ current_user&.user_option&.homepage || HomepageHelper.resolve(request, current_user)
end
def build_plugin_html(name)
@@ -758,6 +758,10 @@ module ApplicationHelper
user&.display_name
end
+ def anonymous_top_menu_items
+ Discourse.anonymous_top_menu_items.map(&:to_s)
+ end
+
def authentication_data
return @authentication_data if defined?(@authentication_data)
diff --git a/app/models/theme_modifier_set.rb b/app/models/theme_modifier_set.rb
index 1d1c8e59f2b..f7fe5a5a5b7 100644
--- a/app/models/theme_modifier_set.rb
+++ b/app/models/theme_modifier_set.rb
@@ -111,6 +111,7 @@ end
# csp_extensions :string is an Array
# svg_icons :string is an Array
# topic_thumbnail_sizes :string is an Array
+# custom_homepage :boolean
#
# Indexes
#
diff --git a/app/models/user_option.rb b/app/models/user_option.rb
index 70c24af6a38..95edf4709f4 100644
--- a/app/models/user_option.rb
+++ b/app/models/user_option.rb
@@ -2,6 +2,7 @@
class UserOption < ActiveRecord::Base
HOMEPAGES = {
+ # -1 => reserved for "custom homepage"
1 => "latest",
2 => "categories",
3 => "unread",
@@ -9,6 +10,7 @@ class UserOption < ActiveRecord::Base
5 => "top",
6 => "bookmarks",
7 => "unseen",
+ # 8 => reserved for "hot"
}
self.ignored_columns = [
@@ -182,11 +184,7 @@ class UserOption < ActiveRecord::Base
def homepage
return HOMEPAGES[homepage_id] if HOMEPAGES.keys.include?(homepage_id)
- if homepage_id == 8 && SiteSetting.top_menu_map.include?("hot")
- "hot"
- else
- SiteSetting.homepage
- end
+ "hot" if homepage_id == 8 && SiteSetting.top_menu_map.include?("hot")
end
def text_size
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 4cd0cc885d6..dba348d7a2d 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -65,7 +65,8 @@ class UserSerializer < UserCardSerializer
:use_logo_small_as_avatar,
:sidebar_tags,
:sidebar_category_ids,
- :display_sidebar_tags
+ :display_sidebar_tags,
+ :can_pick_theme_with_custom_homepage
untrusted_attributes :bio_raw, :bio_cooked, :profile_background_upload_url
@@ -322,6 +323,10 @@ class UserSerializer < UserCardSerializer
SiteSetting.use_site_small_logo_as_system_avatar
end
+ def can_pick_theme_with_custom_homepage
+ ThemeModifierHelper.new(theme_ids: Theme.user_theme_ids).custom_homepage
+ end
+
private
def custom_field_keys
diff --git a/app/serializers/web_hook_user_serializer.rb b/app/serializers/web_hook_user_serializer.rb
index c50c145bf97..9b4b8d0c5c4 100644
--- a/app/serializers/web_hook_user_serializer.rb
+++ b/app/serializers/web_hook_user_serializer.rb
@@ -42,6 +42,7 @@ class WebHookUserSerializer < UserSerializer
display_sidebar_tags
sidebar_category_ids
sidebar_tags
+ can_pick_theme_with_custom_homepage
].each { |attr| define_method("include_#{attr}?") { false } }
def include_email?
diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb
index 2c7acd0bd9f..2ff4501bd48 100644
--- a/app/services/user_updater.rb
+++ b/app/services/user_updater.rb
@@ -129,6 +129,8 @@ class UserUpdater
user.primary_group_id = nil
end
+ attributes[:homepage_id] = nil if attributes[:homepage_id] == "-1"
+
if attributes[:flair_group_id] && attributes[:flair_group_id] != user.flair_group_id &&
(
attributes[:flair_group_id].blank? ||
diff --git a/app/views/default/custom.html.erb b/app/views/default/custom.html.erb
new file mode 100644
index 00000000000..e7363dc51f0
--- /dev/null
+++ b/app/views/default/custom.html.erb
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 06fa501dd19..1c15a446f6f 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1672,6 +1672,9 @@ en:
default: "(default)"
any: "any"
+ homepage:
+ default: "(default)"
+
password_confirmation:
title: "Password Again"
@@ -4183,6 +4186,9 @@ en:
this_week: "Week"
today: "Today"
+ custom_homepage:
+ admin_message: 'One of your themes has enabled the "custom_homepage" modifier but it does not output anything in the [custom-homepage] connector. (This message is only shown to site administrators.)'
+
browser_update: 'Unfortunately, your browser is unsupported. Please switch to a supported browser to view rich content, log in and reply.'
permission_types:
diff --git a/config/routes.rb b/config/routes.rb
index cbfdf133c96..a6b7a7eb66d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1586,6 +1586,8 @@ Discourse::Application.routes.draw do
constraints: HomePageConstraint.new("finish_installation"),
as: "installation_redirect"
+ root to: "custom#index", constraints: HomePageConstraint.new("custom"), as: "custom_index"
+
get "/user-api-key/new" => "user_api_keys#new"
post "/user-api-key" => "user_api_keys#create"
post "/user-api-key/revoke" => "user_api_keys#revoke"
diff --git a/db/migrate/20240326200232_add_custom_homepage_theme_modifiers.rb b/db/migrate/20240326200232_add_custom_homepage_theme_modifiers.rb
new file mode 100644
index 00000000000..4e6222079e8
--- /dev/null
+++ b/db/migrate/20240326200232_add_custom_homepage_theme_modifiers.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddCustomHomepageThemeModifiers < ActiveRecord::Migration[7.0]
+ def change
+ add_column :theme_modifier_sets, :custom_homepage, :boolean, null: true
+ end
+end
diff --git a/lib/homepage_constraint.rb b/lib/homepage_constraint.rb
index 78492e4f018..3944bf1593c 100644
--- a/lib/homepage_constraint.rb
+++ b/lib/homepage_constraint.rb
@@ -9,7 +9,7 @@ class HomePageConstraint
return @filter == "finish_installation" if SiteSetting.has_login_hint?
current_user = CurrentUser.lookup_from_env(request.env)
- homepage = current_user&.user_option&.homepage || SiteSetting.anonymous_homepage
+ homepage = current_user&.user_option&.homepage || HomepageHelper.resolve(request, current_user)
homepage == @filter
rescue Discourse::InvalidAccess, Discourse::ReadOnly
false
diff --git a/lib/homepage_helper.rb b/lib/homepage_helper.rb
new file mode 100644
index 00000000000..c4a77a24482
--- /dev/null
+++ b/lib/homepage_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class HomepageHelper
+ def self.resolve(request = nil, current_user = nil)
+ return "custom" if ThemeModifierHelper.new(request: request).custom_homepage
+
+ current_user ? SiteSetting.homepage : SiteSetting.anonymous_homepage
+ end
+end
diff --git a/spec/lib/homepage_helper_spec.rb b/spec/lib/homepage_helper_spec.rb
new file mode 100644
index 00000000000..c17cbc6c139
--- /dev/null
+++ b/spec/lib/homepage_helper_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+RSpec.describe HomepageHelper do
+ describe "resolver" do
+ fab!(:user)
+
+ it "returns latest by default" do
+ expect(HomepageHelper.resolve).to eq("latest")
+ end
+
+ it "returns custom when theme has a custom homepage" do
+ ThemeModifierHelper.any_instance.expects(:custom_homepage).returns(true)
+
+ expect(HomepageHelper.resolve).to eq("custom")
+ end
+
+ context "when first item in top menu is no valid for anons" do
+ it "distinguishes between auth homepage and anon homepage" do
+ SiteSetting.top_menu = "new|top|latest|unread"
+
+ expect(HomepageHelper.resolve(nil, user)).to eq("new")
+ # new is not a valid route for anon users, anon homepage is next item, top
+ expect(HomepageHelper.resolve).to eq(SiteSetting.anonymous_homepage)
+ expect(HomepageHelper.resolve).to eq("top")
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/schemas/json/user_get_response.json b/spec/requests/api/schemas/json/user_get_response.json
index 0f19c6d1b56..70fc13f671b 100644
--- a/spec/requests/api/schemas/json/user_get_response.json
+++ b/spec/requests/api/schemas/json/user_get_response.json
@@ -336,6 +336,9 @@
"display_sidebar_tags": {
"type": "boolean"
},
+ "can_pick_theme_with_custom_homepage": {
+ "type": "boolean"
+ },
"user_auth_tokens": {
"type": "array",
"items":
diff --git a/spec/services/user_updater_spec.rb b/spec/services/user_updater_spec.rb
index b00909866e4..93e8ef3614e 100644
--- a/spec/services/user_updater_spec.rb
+++ b/spec/services/user_updater_spec.rb
@@ -653,5 +653,10 @@ RSpec.describe UserUpdater do
expect(UserHistory.last.action).to eq(UserHistory.actions[:change_name])
end
+
+ it "clears the homepage_id when the special 'custom' id is chosen" do
+ UserUpdater.new(user, user).update(homepage_id: "-1")
+ expect(user.user_option.homepage_id).to eq(nil)
+ end
end
end
diff --git a/spec/system/homepage_spec.rb b/spec/system/homepage_spec.rb
new file mode 100644
index 00000000000..56c851c5dda
--- /dev/null
+++ b/spec/system/homepage_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+describe "Homepage", type: :system do
+ fab!(:admin)
+ fab!(:user)
+ fab!(:topics) { Fabricate.times(5, :post).map(&:topic) }
+ let(:discovery) { PageObjects::Pages::Discovery.new }
+ let!(:theme) { Fabricate(:theme) }
+
+ before do
+ # A workaround to avoid the global notice from interfering with the tests
+ # It is coming from the ensure_login_hint.rb initializer and it gets
+ # evaluated before the tests run (and it wrongly counts 0 admins defined)
+ SiteSetting.global_notice = nil
+ end
+
+ it "shows a list of topics by default" do
+ visit "/"
+ expect(discovery.topic_list).to have_topics(count: 5)
+ end
+
+ it "allows users to pick their homepage" do
+ sign_in user
+ visit "/"
+
+ expect(page).to have_css(".navigation-container .latest.active", text: "Latest")
+
+ visit "u/#{user.username}/preferences/interface"
+
+ homepage_picker = PageObjects::Components::SelectKit.new("#home-selector")
+ homepage_picker.expand
+ homepage_picker.select_row_by_name("Top")
+ page.find(".btn-primary.save-changes").click
+
+ # Wait for the save to complete
+ find(".btn-primary.save-changes:not([disabled])", wait: 5)
+
+ visit "/"
+
+ expect(page).to have_css(".navigation-container .top.active", text: "Top")
+ expect(page).to have_css(".top-lists")
+ end
+
+ it "defaults to first top_menu item as anonymous homepage" do
+ SiteSetting.top_menu = "categories|latest|new|unread"
+ visit "/"
+
+ expect(page).to have_css(".navigation-container .categories.active", text: "Categories")
+
+ sign_in user
+ visit "/"
+
+ expect(page).to have_css(".navigation-container .categories.active", text: "Categories")
+ end
+
+ context "when default theme uses a custom_homepage modifier" do
+ before do
+ theme.theme_modifier_set.custom_homepage = true
+ theme.theme_modifier_set.save!
+ theme.set_default!
+ end
+
+ it "shows empty state to regular users" do
+ sign_in user
+ visit "/"
+
+ expect(page).to have_no_css(".list-container")
+ expect(page).to have_no_css(".alert-info")
+ end
+
+ it "shows empty state and notice to admins" do
+ sign_in admin
+ visit "/"
+
+ expect(page).to have_no_css(".list-container")
+ expect(page).to have_css(".alert-info")
+ end
+
+ context "when the theme adds content to the [custom-homepage] connector" do
+ let!(:basic_html_field) do
+ Fabricate(
+ :theme_field,
+ theme: theme,
+ type_id: ThemeField.types[:html],
+ target_id: Theme.targets[:common],
+ name: "head_tag",
+ value: <<~HTML,
+
+ HTML
+ )
+ end
+
+ it "shows the custom homepage from the theme on the homepage" do
+ visit "/"
+
+ expect(page).to have_css(".new-home", text: "Hi friends!")
+ expect(page).to have_no_css(".list-container")
+
+ find("#sidebar-section-content-community .sidebar-section-link:first-child").click
+ expect(page).to have_css(".list-container")
+
+ find("#site-logo").click
+
+ expect(page).to have_no_css(".list-container")
+ # ensure clicking on logo brings user back to the custom homepage
+ expect(page).to have_css(".new-home", text: "Hi friends!")
+ end
+
+ it "respects the user's homepage choice" do
+ visit "/"
+
+ expect(page).not_to have_css(".list-container")
+ expect(page).to have_css(".new-home", text: "Hi friends!")
+
+ sign_in user
+
+ visit "/u/#{user.username}/preferences/interface"
+
+ homepage_picker = PageObjects::Components::SelectKit.new("#home-selector")
+ homepage_picker.expand
+ # user overrides theme custom homepage
+ homepage_picker.select_row_by_name("Top")
+ page.find(".btn-primary.save-changes").click
+
+ # Wait for the save to complete
+ find(".btn-primary.save-changes:not([disabled])", wait: 5)
+
+ find("#site-logo").click
+
+ expect(page).to have_css(".navigation-container .top.active", text: "Top")
+ expect(page).to have_css(".top-lists")
+
+ visit "/u/#{user.username}/preferences/interface"
+
+ homepage_picker = PageObjects::Components::SelectKit.new("#home-selector")
+ homepage_picker.expand
+ # user selects theme custom homepage again
+ homepage_picker.select_row_by_name("(default)")
+ page.find(".btn-primary.save-changes").click
+
+ # Wait for the save to complete
+ find(".btn-primary.save-changes:not([disabled])", wait: 5)
+ find("#site-logo").click
+
+ expect(page).not_to have_css(".list-container")
+ expect(page).to have_css(".new-home", text: "Hi friends!")
+ end
+ end
+ end
+end