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.
This commit is contained in:
Penar Musaraj 2024-04-02 11:05:08 -04:00 committed by GitHub
parent 7eec13375d
commit 1eb70973a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 271 additions and 9 deletions

View File

@ -186,6 +186,11 @@ export default Controller.extend({
homeChanged() { homeChanged() {
const siteHome = this.siteSettings.top_menu.split("|")[0].split(",")[0]; 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")]; const userHome = USER_HOMES[this.get("model.user_option.homepage_id")];
setDefaultHomepage(userHome || siteHome); setDefaultHomepage(userHome || siteHome);
@ -200,6 +205,14 @@ export default Controller.extend({
}); });
let result = []; let result = [];
if (this.model.canPickThemeWithCustomHomepage) {
result.push({
name: I18n.t("user.homepage.default"),
value: -1,
});
}
this.siteSettings.top_menu.split("|").forEach((m) => { this.siteSettings.top_menu.split("|").forEach((m) => {
let id = homeValues[m]; let id = homeValues[m];
if (id) { if (id) {

View File

@ -209,6 +209,7 @@ export default class User extends RestModel.extend(Evented) {
@alias("sidebar_sections") sidebarSections; @alias("sidebar_sections") sidebarSections;
@mapBy("sidebarTags", "name") sidebarTagNames; @mapBy("sidebarTags", "name") sidebarTagNames;
@filterBy("groups", "has_messages", true) groupsWithMessages; @filterBy("groups", "has_messages", true) groupsWithMessages;
@alias("can_pick_theme_with_custom_homepage") canPickThemeWithCustomHomepage;
numGroupsToDisplay = 2; numGroupsToDisplay = 2;

View File

@ -63,6 +63,8 @@ export default function () {
this.route("categoryNone", { path: "/c/*category_slug_path_with_id/none" }); this.route("categoryNone", { path: "/c/*category_slug_path_with_id/none" });
this.route("categoryAll", { path: "/c/*category_slug_path_with_id/all" }); this.route("categoryAll", { path: "/c/*category_slug_path_with_id/all" });
this.route("category", { path: "/c/*category_slug_path_with_id" }); this.route("category", { path: "/c/*category_slug_path_with_id" });
this.route("custom");
}); });
this.route("groups", { resetNamespace: true, path: "/g" }, function () { this.route("groups", { resetNamespace: true, path: "/g" }, function () {

View File

@ -0,0 +1,7 @@
<PluginOutlet @name="custom-homepage">
{{#if this.currentUser.admin}}
<p class="alert alert-info">
{{i18n "custom_homepage.admin_message"}}
</p>
{{/if}}
</PluginOutlet>

View File

@ -519,7 +519,7 @@ class ApplicationController < ActionController::Base
end end
def current_homepage def current_homepage
current_user&.user_option&.homepage || SiteSetting.anonymous_homepage current_user&.user_option&.homepage || HomepageHelper.resolve(request, current_user)
end end
def serialize_data(obj, serializer, opts = nil) def serialize_data(obj, serializer, opts = nil)

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class CustomHomepageController < ApplicationController
def index
render "default/custom"
end
end

View File

@ -559,7 +559,7 @@ module ApplicationHelper
end end
def current_homepage def current_homepage
current_user&.user_option&.homepage || SiteSetting.anonymous_homepage current_user&.user_option&.homepage || HomepageHelper.resolve(request, current_user)
end end
def build_plugin_html(name) def build_plugin_html(name)
@ -758,6 +758,10 @@ module ApplicationHelper
user&.display_name user&.display_name
end end
def anonymous_top_menu_items
Discourse.anonymous_top_menu_items.map(&:to_s)
end
def authentication_data def authentication_data
return @authentication_data if defined?(@authentication_data) return @authentication_data if defined?(@authentication_data)

View File

@ -111,6 +111,7 @@ end
# csp_extensions :string is an Array # csp_extensions :string is an Array
# svg_icons :string is an Array # svg_icons :string is an Array
# topic_thumbnail_sizes :string is an Array # topic_thumbnail_sizes :string is an Array
# custom_homepage :boolean
# #
# Indexes # Indexes
# #

View File

@ -2,6 +2,7 @@
class UserOption < ActiveRecord::Base class UserOption < ActiveRecord::Base
HOMEPAGES = { HOMEPAGES = {
# -1 => reserved for "custom homepage"
1 => "latest", 1 => "latest",
2 => "categories", 2 => "categories",
3 => "unread", 3 => "unread",
@ -9,6 +10,7 @@ class UserOption < ActiveRecord::Base
5 => "top", 5 => "top",
6 => "bookmarks", 6 => "bookmarks",
7 => "unseen", 7 => "unseen",
# 8 => reserved for "hot"
} }
self.ignored_columns = [ self.ignored_columns = [
@ -182,11 +184,7 @@ class UserOption < ActiveRecord::Base
def homepage def homepage
return HOMEPAGES[homepage_id] if HOMEPAGES.keys.include?(homepage_id) return HOMEPAGES[homepage_id] if HOMEPAGES.keys.include?(homepage_id)
if homepage_id == 8 && SiteSetting.top_menu_map.include?("hot") "hot" if homepage_id == 8 && SiteSetting.top_menu_map.include?("hot")
"hot"
else
SiteSetting.homepage
end
end end
def text_size def text_size

View File

@ -65,7 +65,8 @@ class UserSerializer < UserCardSerializer
:use_logo_small_as_avatar, :use_logo_small_as_avatar,
:sidebar_tags, :sidebar_tags,
:sidebar_category_ids, :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 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 SiteSetting.use_site_small_logo_as_system_avatar
end end
def can_pick_theme_with_custom_homepage
ThemeModifierHelper.new(theme_ids: Theme.user_theme_ids).custom_homepage
end
private private
def custom_field_keys def custom_field_keys

View File

@ -42,6 +42,7 @@ class WebHookUserSerializer < UserSerializer
display_sidebar_tags display_sidebar_tags
sidebar_category_ids sidebar_category_ids
sidebar_tags sidebar_tags
can_pick_theme_with_custom_homepage
].each { |attr| define_method("include_#{attr}?") { false } } ].each { |attr| define_method("include_#{attr}?") { false } }
def include_email? def include_email?

View File

@ -129,6 +129,8 @@ class UserUpdater
user.primary_group_id = nil user.primary_group_id = nil
end end
attributes[:homepage_id] = nil if attributes[:homepage_id] == "-1"
if attributes[:flair_group_id] && attributes[:flair_group_id] != user.flair_group_id && if attributes[:flair_group_id] && attributes[:flair_group_id] != user.flair_group_id &&
( (
attributes[:flair_group_id].blank? || attributes[:flair_group_id].blank? ||

View File

@ -0,0 +1,9 @@
<ul>
<% anonymous_top_menu_items.each_with_index do |item, index| %>
<li>
<a href='<%= Discourse.base_url %>/<%= item %>'>
<%= I18n.t("js.filters.#{item}.title") %>
</a>
</li>
<% end %>
</ul>

View File

@ -1672,6 +1672,9 @@ en:
default: "(default)" default: "(default)"
any: "any" any: "any"
homepage:
default: "(default)"
password_confirmation: password_confirmation:
title: "Password Again" title: "Password Again"
@ -4183,6 +4186,9 @@ en:
this_week: "Week" this_week: "Week"
today: "Today" 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, <a href="https://www.discourse.org/faq/#browser">your browser is unsupported</a>. Please <a href="https://browsehappy.com">switch to a supported browser</a> to view rich content, log in and reply.' browser_update: 'Unfortunately, <a href="https://www.discourse.org/faq/#browser">your browser is unsupported</a>. Please <a href="https://browsehappy.com">switch to a supported browser</a> to view rich content, log in and reply.'
permission_types: permission_types:

View File

@ -1586,6 +1586,8 @@ Discourse::Application.routes.draw do
constraints: HomePageConstraint.new("finish_installation"), constraints: HomePageConstraint.new("finish_installation"),
as: "installation_redirect" as: "installation_redirect"
root to: "custom#index", constraints: HomePageConstraint.new("custom"), as: "custom_index"
get "/user-api-key/new" => "user_api_keys#new" get "/user-api-key/new" => "user_api_keys#new"
post "/user-api-key" => "user_api_keys#create" post "/user-api-key" => "user_api_keys#create"
post "/user-api-key/revoke" => "user_api_keys#revoke" post "/user-api-key/revoke" => "user_api_keys#revoke"

View File

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

View File

@ -9,7 +9,7 @@ class HomePageConstraint
return @filter == "finish_installation" if SiteSetting.has_login_hint? return @filter == "finish_installation" if SiteSetting.has_login_hint?
current_user = CurrentUser.lookup_from_env(request.env) 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 homepage == @filter
rescue Discourse::InvalidAccess, Discourse::ReadOnly rescue Discourse::InvalidAccess, Discourse::ReadOnly
false false

9
lib/homepage_helper.rb Normal file
View File

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

View File

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

View File

@ -336,6 +336,9 @@
"display_sidebar_tags": { "display_sidebar_tags": {
"type": "boolean" "type": "boolean"
}, },
"can_pick_theme_with_custom_homepage": {
"type": "boolean"
},
"user_auth_tokens": { "user_auth_tokens": {
"type": "array", "type": "array",
"items": "items":

View File

@ -653,5 +653,10 @@ RSpec.describe UserUpdater do
expect(UserHistory.last.action).to eq(UserHistory.actions[:change_name]) expect(UserHistory.last.action).to eq(UserHistory.actions[:change_name])
end 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
end end

View File

@ -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,
<script type="text/x-handlebars" data-template-name="/connectors/custom-homepage/new-home">
<div class="new-home">Hi friends!</div>
</script>
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