From ca5a1f53f260d184184de66824c07c736e94f8d0 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 28 May 2026 19:18:33 +0100 Subject: [PATCH] Allow custom display names --- app/controllers/profiles_controller.rb | 4 +- app/controllers/settings/base_controller.rb | 5 ++- .../settings/profile_controller.rb | 5 ++- .../pages/Users/Settings/Profile.svelte | 36 +++++++++++++++++ app/javascript/pages/Users/Settings/types.ts | 18 ++++++++- app/models/user.rb | 12 ++++++ app/services/profile_og_image_generator.rb | 2 +- config/initializers/js_from_routes.rb | 1 + config/routes.rb | 1 + .../settings_profile_controller_test.rb | 37 +++++++++++++++++ test/models/user_test.rb | 40 +++++++++++++++++++ 11 files changed, 154 insertions(+), 7 deletions(-) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index cd7b9cb4a..a64356d4e 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -107,7 +107,7 @@ def set_profile_social_preview def profile_social_description return @user.profile_bio.to_s.squish.truncate(180) if @user.profile_bio.present? - "View #{@user.display_name_override.presence || @user.display_name}'s Hackatime coding profile." + "View #{@user.display_name}'s Hackatime coding profile." end def ensure_profile_og_image! @@ -159,7 +159,7 @@ def public_profile_og_heatmap end def profile_summary_payload - { display_name: @user.display_name_override.presence || @user.display_name, + { display_name: @user.display_name, username: @user.username || "", avatar_url: @user.avatar_url, trust_level: @user.public_trust_level, bio: @user.profile_bio, social_links: profile_social_links, github_profile_url: @user.github_profile_url, diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index f2addd267..5647e01be 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -36,7 +36,9 @@ def common_props(active_section:) page_title: (is_own ? "My Settings" : "Settings | #{@user.display_name}"), heading: (is_own ? "Settings" : "Settings for #{@user.display_name}"), subheading: "Manage your profile, appearance, editors, integrations, privacy, goals, and data tools.", - errors: { full_messages: @user.errors.full_messages, username: @user.errors[:username] } } + errors: { full_messages: @user.errors.full_messages, + display_name_override: @user.errors[:display_name_override], + username: @user.errors[:username] } } end # Subclasses override this to provide section-specific props @@ -45,6 +47,7 @@ def section_props = {} USER_PROP_BUILDERS = { id: ->(u) { u.id }, display_name: ->(u) { u.display_name }, + display_name_override: ->(u) { u.display_name_override }, timezone: ->(u) { u.timezone }, country_code: ->(u) { u.country_code }, username: ->(u) { u.username }, diff --git a/app/controllers/settings/profile_controller.rb b/app/controllers/settings/profile_controller.rb index 02df027d2..c5a63d081 100644 --- a/app/controllers/settings/profile_controller.rb +++ b/app/controllers/settings/profile_controller.rb @@ -1,6 +1,7 @@ class Settings::ProfileController < Settings::BaseController def show = render_profile def update_region = update_section(region_params) + def update_display_name = update_section(display_name_params) def update_username = update_section(username_params) private @@ -12,7 +13,8 @@ def render_profile(status: :ok) def section_props { username_max_length: User::USERNAME_MAX_LENGTH, - user: user_props(keys: %i[country_code timezone username]), + display_name_max_length: User::DISPLAY_NAME_MAX_LENGTH, + user: user_props(keys: %i[country_code timezone display_name display_name_override username]), options: base_options(keys: %i[countries timezones]), profile_url: (@user.username.present? ? "https://hackati.me/#{@user.username}" : nil), emails: @user.email_addresses.map { |email| @@ -38,5 +40,6 @@ def region_params permitted end + def display_name_params = params.require(:user).permit(:display_name_override) def username_params = params.require(:user).permit(:username) end diff --git a/app/javascript/pages/Users/Settings/Profile.svelte b/app/javascript/pages/Users/Settings/Profile.svelte index 77497e40a..e409d10b4 100644 --- a/app/javascript/pages/Users/Settings/Profile.svelte +++ b/app/javascript/pages/Users/Settings/Profile.svelte @@ -14,6 +14,7 @@ heading, subheading, username_max_length, + display_name_max_length, user, options, profile_url, @@ -71,6 +72,41 @@ {/snippet} + +
+ + + +
+ + {#snippet footer()} + + {/snippet} +
+ ; + display_name_max_length: number; + user: Pick< + UserProps, + | "country_code" + | "timezone" + | "display_name" + | "display_name_override" + | "username" + >; options: Pick; profile_url: string | null; emails: EmailProps[]; @@ -238,7 +248,10 @@ export type AppearancePageProps = SettingsCommonProps & { }; export type EditorsPageProps = SettingsCommonProps & { - user: Pick; + user: Pick< + UserProps, + "hackatime_extension_text_type" | "show_goals_in_statusbar" + >; options: Pick; }; @@ -309,6 +322,7 @@ export const buildSections = (): SettingsSection[] => [ const subsectionMap: Record = { profile: [ { id: "user_region", label: "Region" }, + { id: "user_display_name", label: "Display name" }, { id: "user_username", label: "Username" }, { id: "user_email_addresses", label: "Email addresses" }, ], diff --git a/app/models/user.rb b/app/models/user.rb index ea43b8f8c..acea26670 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,6 +11,7 @@ class User < ApplicationRecord has_subscriptions USERNAME_MAX_LENGTH = 21 # going over 21 overflows the navbar + DISPLAY_NAME_MAX_LENGTH = 80 has_paper_trail @@ -18,6 +19,7 @@ class User < ApplicationRecord after_create_commit :schedule_onboarding_check_in_email after_update_commit :clear_leaderboard_page_cache, if: :saved_change_to_leaderboard_shadowban_state? before_validation :normalize_username + before_validation :normalize_display_name_override encrypts :slack_access_token, :github_access_token, :hca_access_token validates :slack_uid, uniqueness: true, allow_nil: true @@ -29,6 +31,7 @@ class User < ApplicationRecord format: { with: /\A[A-Za-z0-9_-]+\z/, message: "may only include letters, numbers, '-', and '_'" }, uniqueness: { case_sensitive: false, message: "has already been taken" }, allow_nil: true + validates :display_name_override, length: { maximum: DISPLAY_NAME_MAX_LENGTH }, allow_nil: true validates :leaderboard_shadowban_reason, presence: true, if: :leaderboard_shadowbanned? validate :username_must_be_visible @@ -306,6 +309,9 @@ def avatar_url end def display_name + name = display_name_override.presence + return name if name.present? + name = slack_username || github_username || username return name if name.present? email = email_addresses&.first&.email @@ -394,4 +400,10 @@ def username_must_be_visible return unless instance_variable_defined?(:@username_cleared_for_invisible) && @username_cleared_for_invisible errors.add(:username, "must include visible characters") end + + def normalize_display_name_override + return if display_name_override.nil? + + self.display_name_override = display_name_override.gsub(/\p{Cf}/, "").strip.presence + end end diff --git a/app/services/profile_og_image_generator.rb b/app/services/profile_og_image_generator.rb index d6a663e8f..c1f24bc9f 100644 --- a/app/services/profile_og_image_generator.rb +++ b/app/services/profile_og_image_generator.rb @@ -75,7 +75,7 @@ def self.template end def display_name - @display_name ||= user.display_name_override.presence || user.display_name + @display_name ||= user.display_name end def username diff --git a/config/initializers/js_from_routes.rb b/config/initializers/js_from_routes.rb index 28eaec964..5323a9aca 100644 --- a/config/initializers/js_from_routes.rb +++ b/config/initializers/js_from_routes.rb @@ -46,6 +46,7 @@ module JsFromRoutes my_settings my_settings_profile my_settings_profile_region + my_settings_profile_display_name my_settings_profile_username my_settings_setup my_settings_appearance diff --git a/config/routes.rb b/config/routes.rb index 2ff3afddb..379ff1ca7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -155,6 +155,7 @@ def matches?(request) # Profile get "my/settings/profile", to: "settings/profile#show", as: :my_settings_profile patch "my/settings/profile/region", to: "settings/profile#update_region", as: :my_settings_profile_region + patch "my/settings/profile/display_name", to: "settings/profile#update_display_name", as: :my_settings_profile_display_name patch "my/settings/profile/username", to: "settings/profile#update_username", as: :my_settings_profile_username # Setup diff --git a/test/controllers/settings_profile_controller_test.rb b/test/controllers/settings_profile_controller_test.rb index 8d0290e8b..e1d04f31b 100644 --- a/test/controllers/settings_profile_controller_test.rb +++ b/test/controllers/settings_profile_controller_test.rb @@ -25,6 +25,43 @@ class SettingsProfileControllerTest < ActionDispatch::IntegrationTest assert_nil user.reload.country_code end + test "display name update persists override" do + user = users(:one) + user.update!(slack_username: "slack_name") + sign_in_as(user) + + patch my_settings_profile_display_name_path, params: { user: { display_name_override: "Custom Name" } } + + assert_response :redirect + assert_redirected_to my_settings_profile_path + assert_equal "Custom Name", user.reload.display_name_override + assert_equal "Custom Name", user.display_name + end + + test "display name update clears blank override" do + user = users(:one) + user.update!(display_name_override: "Custom Name", slack_username: "slack_name") + sign_in_as(user) + + patch my_settings_profile_display_name_path, params: { user: { display_name_override: " " } } + + assert_response :redirect + assert_nil user.reload.display_name_override + assert_equal "slack_name", user.display_name + end + + test "display name update with invalid display name returns unprocessable entity" do + user = users(:one) + sign_in_as(user) + + patch my_settings_profile_display_name_path, params: { + user: { display_name_override: "a" * (User::DISPLAY_NAME_MAX_LENGTH + 1) } + } + + assert_response :unprocessable_entity + assert_inertia_component "Users/Settings/Profile" + end + test "username update with invalid username returns unprocessable entity" do user = users(:one) user.update!(username: "good_name") diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 025265ecf..678b36485 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -62,6 +62,46 @@ class UserTest < ActiveSupport::TestCase assert_equal "User;#{user.id}", user.flipper_id end + test "display name override takes precedence over synced provider names" do + user = User.create!( + timezone: "UTC", + username: "profile_user", + slack_username: "slack_user", + github_username: "github_user", + display_name_override: "Custom Name" + ) + + assert_equal "Custom Name", user.display_name + end + + test "display name override is normalized before validation" do + user = User.create!(timezone: "UTC", slack_username: "slack_user", display_name_override: " Custom Name ") + + assert_equal "Custom Name", user.display_name_override + end + + test "slack profile sync does not replace display name override" do + user = User.create!( + timezone: "UTC", + slack_username: "old_slack", + display_name_override: "Custom Name" + ) + + user.apply_slack_profile_attributes({ + "name" => "fallback", + "profile" => { + "display_name_normalized" => "new_slack", + "real_name_normalized" => "Real Name", + "image_192" => "https://example.com/avatar.png" + } + }) + user.save! + + assert_equal "new_slack", user.reload.slack_username + assert_equal "Custom Name", user.display_name_override + assert_equal "Custom Name", user.display_name + end + test "active remote heartbeat import run only counts remote imports" do user = User.create!(timezone: "UTC")