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")