From 1eb70973a2ed770e6c87c38b67990107e746cc0d Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 2 Apr 2024 11:05:08 -0400 Subject: [PATCH] DEV: allow themes to render their own custom homepage (#26291) This PR adds a theme modifier and route so that custom themes can opt to show their own homepage. See PR description for example usage. --- .../app/controllers/preferences/interface.js | 13 ++ .../javascripts/discourse/app/models/user.js | 1 + .../discourse/app/routes/app-route-map.js | 2 + .../app/templates/discovery/custom.hbs | 7 + app/controllers/application_controller.rb | 2 +- app/controllers/custom_homepage_controller.rb | 7 + app/helpers/application_helper.rb | 6 +- app/models/theme_modifier_set.rb | 1 + app/models/user_option.rb | 8 +- app/serializers/user_serializer.rb | 7 +- app/serializers/web_hook_user_serializer.rb | 1 + app/services/user_updater.rb | 2 + app/views/default/custom.html.erb | 9 ++ config/locales/client.en.yml | 6 + config/routes.rb | 2 + ...232_add_custom_homepage_theme_modifiers.rb | 7 + lib/homepage_constraint.rb | 2 +- lib/homepage_helper.rb | 9 ++ spec/lib/homepage_helper_spec.rb | 28 ++++ .../api/schemas/json/user_get_response.json | 3 + spec/services/user_updater_spec.rb | 5 + spec/system/homepage_spec.rb | 152 ++++++++++++++++++ 22 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/templates/discovery/custom.hbs create mode 100644 app/controllers/custom_homepage_controller.rb create mode 100644 app/views/default/custom.html.erb create mode 100644 db/migrate/20240326200232_add_custom_homepage_theme_modifiers.rb create mode 100644 lib/homepage_helper.rb create mode 100644 spec/lib/homepage_helper_spec.rb create mode 100644 spec/system/homepage_spec.rb 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