diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a4bb39e0764d235f25343ca497cc6f381d9eb653..31d45ad3a28559f632bed89ec25e3ab738083be7 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -655,6 +655,7 @@ $status-icon-size: 22px; */ $discord: #5865f2; $linkedin: #2867b2; +$mastodon: #6364ff; $skype: #0078d7; $twitter: #1d9bf0; diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss index dbe82f583d1e898a2cb974cf9b8b8bb15b54bdab..6b31c6678323f90ddfe25c811839d3f0e6f1cd3e 100644 --- a/app/assets/stylesheets/page_bundles/profile.scss +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -242,6 +242,10 @@ color: $discord; } +.mastodon-icon { + color: $mastodon; +} + .key-created-at { line-height: 42px; } diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 1f05e4e7b21c267ae5712ee4d86f0c8f8179d7e8..50e0c5cc5ffedabd8d5d57567a4be266e6f62fd0 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -342,6 +342,7 @@ def allowed_user_params :bio, :can_create_group, :color_scheme_id, + :discord, :email, :extern_uid, :external, @@ -350,6 +351,7 @@ def allowed_user_params :hide_no_ssh_key, :key_id, :linkedin, + :mastodon, :name, :password_expires_at, :projects_limit, @@ -358,7 +360,6 @@ def allowed_user_params :skype, :theme_id, :twitter, - :discord, :username, :website_url, :note, diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index da15b393e6c9bade0a5984330fc1903169d1de78..cb29f0f35392ca023583d563e80789208244696b 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -111,6 +111,7 @@ def user_params_attributes [ :avatar, :bio, + :discord, :email, :role, :gitpod_enabled, @@ -119,12 +120,12 @@ def user_params_attributes :hide_project_limit, :linkedin, :location, + :mastodon, :name, :public_email, :commit_email, :skype, :twitter, - :discord, :username, :website_url, :organization, diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 579373539558c01b01374821347f308d4d5bfda7..541a71d3302f80afbdaa9406dbac11fe5d1d8913 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -371,6 +371,14 @@ def discord_url(user) "https://discord.com/users/#{user.discord}" end + def mastodon_url(user) + return '' if user.mastodon.blank? + + url = user.mastodon.match UserDetail::MASTODON_VALIDATION_REGEX + + "https://#{url[2]}/@#{url[1]}" + end + def collapsed_sidebar? cookies["sidebar_collapsed"] == "true" end diff --git a/app/models/user.rb b/app/models/user.rb index 4034677509f82500a1aaacad94de5b2e57b8126a..74a09c966f725ad2a436f42d11802ed89d2dfd4b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -417,6 +417,7 @@ def update_tracked_fields!(request) delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true + delegate :mastodon, :mastodon=, to: :user_detail, allow_nil: true delegate :linkedin, :linkedin=, to: :user_detail, allow_nil: true delegate :twitter, :twitter=, to: :user_detail, allow_nil: true delegate :skype, :skype=, to: :user_detail, allow_nil: true diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 9ac814eebdabd31ecd954fc48858b2dd85969836..bbb08ed577430c047ddd50b96e9ba58124a57460 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -17,10 +17,24 @@ class UserDetail < MainClusterwide::ApplicationRecord DEFAULT_FIELD_LENGTH = 500 + MASTODON_VALIDATION_REGEX = / + \A # beginning of string + @?\b # optional leading at + ([\w\d.%+-]+) # character group to pick up words in user portion of username + @ # separator between user and host + ( # beginning of charagter group for host portion + [\w\d.-]+ # character group to pick up words in host portion of username + \.\w{2,} # pick up tld of host domain, 2 chars or more + )\b # end of character group to pick up words in host portion of username + \z # end of string + /x + validates :discord, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validate :discord_format validates :linkedin, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :location, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :mastodon, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validate :mastodon_format validates :organization, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :skype, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :twitter, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true @@ -32,7 +46,7 @@ class UserDetail < MainClusterwide::ApplicationRecord enum registration_objective: REGISTRATION_OBJECTIVE_PAIRS, _suffix: true def sanitize_attrs - %i[discord linkedin skype twitter website_url].each do |attr| + %i[discord linkedin mastodon skype twitter website_url].each do |attr| value = self[attr] self[attr] = Sanitize.clean(value) if value.present? end @@ -49,6 +63,7 @@ def prevent_nil_fields self.discord = '' if discord.nil? self.linkedin = '' if linkedin.nil? self.location = '' if location.nil? + self.mastodon = '' if mastodon.nil? self.organization = '' if organization.nil? self.skype = '' if skype.nil? self.twitter = '' if twitter.nil? @@ -62,4 +77,10 @@ def discord_format errors.add(:discord, _('must contain only a discord user ID.')) end +def mastodon_format + return if mastodon.blank? || mastodon =~ UserDetail::MASTODON_VALIDATION_REGEX + + errors.add(:mastodon, _('must contain only a mastodon username.')) +end + UserDetail.prepend_mod_with('UserDetail') diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 4da48771ba341a5c4d9d095bfe54afaa4f64af01..002953b165831c64b60451e3a9914788fce88e58 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -122,6 +122,10 @@ allow_empty: true} %small.form-text.text-gl-muted = external_accounts_docs_link + - if Feature.enabled?(:mastodon_social_ui, @user) + .form-group.gl-form-group + = f.label :mastodon + = f.text_field :mastodon, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "@robin@example.com" .form-group.gl-form-group = f.label :website_url, s_('Profiles|Website url') diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 0881c5bba5430a50bc2cf7ccfa82434ce14f51a2..29360dc7c9484d21720dd24c8fe1cfb4e5d99aba 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -111,6 +111,10 @@ = render 'middle_dot_divider', breakpoint: 'sm' do = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do = sprite_icon('discord', css_class: 'discord-icon') + - if Feature.enabled?(:mastodon_social_ui, @user) && @user.mastodon.present? + = render 'middle_dot_divider', breakpoint: 'sm' do + = link_to mastodon_url(@user), class: 'gl-hover-text-decoration-none', title: "Mastodon", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('mastodon', css_class: 'mastodon-icon') - if @user.website_url.present? = render 'middle_dot_divider', stacking: true do - if Feature.enabled?(:security_auto_fix) && @user.bot? diff --git a/config/feature_flags/development/mastodon_social_ui.yml b/config/feature_flags/development/mastodon_social_ui.yml new file mode 100644 index 0000000000000000000000000000000000000000..5e04d8176e4924d1353dfb4d61f315aeeac1188b --- /dev/null +++ b/config/feature_flags/development/mastodon_social_ui.yml @@ -0,0 +1,8 @@ +--- +name: mastodon_social_ui +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132892 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/428163 +milestone: '16.5' +type: development +group: group::tenant scale +default_enabled: false diff --git a/db/migrate/20230927124202_add_mastodon_to_user_details.rb b/db/migrate/20230927124202_add_mastodon_to_user_details.rb new file mode 100644 index 0000000000000000000000000000000000000000..a1aa099087b98c51a3ccea141777e3a930e082c2 --- /dev/null +++ b/db/migrate/20230927124202_add_mastodon_to_user_details.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddMastodonToUserDetails < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + USER_DETAILS_FIELD_LIMIT = 500 + + def up + with_lock_retries do + add_column :user_details, :mastodon, :text, default: '', null: false, if_not_exists: true + end + + add_text_limit :user_details, :mastodon, USER_DETAILS_FIELD_LIMIT + end + + def down + with_lock_retries do + remove_column :user_details, :mastodon + end + end +end diff --git a/db/schema_migrations/20230927124202 b/db/schema_migrations/20230927124202 new file mode 100644 index 0000000000000000000000000000000000000000..a4089994e9799c7cb3aed26b67930ce7d6a3aef2 --- /dev/null +++ b/db/schema_migrations/20230927124202 @@ -0,0 +1 @@ +652375e6b7318fe85b4b23eac3cce88618136341cee7721522adacbe52a52c66 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d7d5d469d9eb12a7f5199c0ecd6758671a271089..6a9cdb508214152b66349728a56662b5d5a7536a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -24088,6 +24088,7 @@ CREATE TABLE user_details ( enterprise_group_id bigint, enterprise_group_associated_at timestamp with time zone, email_reset_offered_at timestamp with time zone, + mastodon text DEFAULT ''::text NOT NULL, CONSTRAINT check_245664af82 CHECK ((char_length(webauthn_xid) <= 100)), CONSTRAINT check_444573ee52 CHECK ((char_length(skype) <= 500)), CONSTRAINT check_466a25be35 CHECK ((char_length(twitter) <= 500)), @@ -24099,6 +24100,7 @@ CREATE TABLE user_details ( CONSTRAINT check_8a7fcf8a60 CHECK ((char_length(location) <= 500)), CONSTRAINT check_a73b398c60 CHECK ((char_length(phone) <= 50)), CONSTRAINT check_eeeaf8d4f0 CHECK ((char_length(pronouns) <= 50)), + CONSTRAINT check_f1a8a05b9a CHECK ((char_length(mastodon) <= 500)), CONSTRAINT check_f932ed37db CHECK ((char_length(pronunciation) <= 255)) ); diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md index 6536a992292e4fe0ff6feaa8b9b0be00cff512be..fea9bc491cf9a4b75cae6a5ca86bc16b29cb927d 100644 --- a/doc/user/profile/index.md +++ b/doc/user/profile/index.md @@ -128,6 +128,8 @@ to match your username. ## Add external accounts to your user profile page +> Mastodon user account [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132892) as a beta feature in 16.5 [with a flag](../feature_flags.md) named `mastodon_social_ui`. Disabled by default. + You can add links to certain other external accounts you might have, like Skype and Twitter. They can help other users connect with you on other platforms. @@ -138,6 +140,7 @@ To add links to other accounts: 1. In the **Main settings** section, add your: - Discord [user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-). - LinkedIn profile name. + - Mastodon username. - Skype username. - Twitter @username. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f4e362f05d15f8a22434ea09a5358f177ee71020..9c223a5381cceaa246a4313936cc58ecc402c494 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -57148,6 +57148,9 @@ msgstr "" msgid "must contain only a discord user ID." msgstr "" +msgid "must contain only a mastodon username." +msgstr "" + msgid "must have a repository" msgstr "" diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index 2bcb47f97ab00a25c11dddcb99b3123e2a33c4e4..4f350ddf1ef4da4a3d452e4531747f19a3eda8f3 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -128,6 +128,16 @@ expect(user.reload.discord).to eq(discord_user_id) expect(response).to have_gitlab_http_status(:found) end + + it 'allows updating user specified mastodon username', :aggregate_failures do + mastodon_username = '@robin@example.com' + sign_in(user) + + put :update, params: { user: { mastodon: mastodon_username } } + + expect(user.reload.mastodon).to eq(mastodon_username) + expect(response).to have_gitlab_http_status(:found) + end end describe 'GET audit_log' do diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 7cf64c6e049befd763793b899f83dbc64c6d21bc..3e95cb25b1208f9cdc167e69298d6cba2dbd0d1c 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -637,6 +637,21 @@ def stub_controller_method(method_name, value) expect(discord).to eq('https://discord.com/users/1234567890123456789') end end + + context 'when mastodon is set' do + let_it_be(:user) { build(:user) } + let(:mastodon) { mastodon_url(user) } + + it 'returns an empty string if mastodon username is not set' do + expect(mastodon).to eq('') + end + + it 'returns mastodon url when mastodon username is set' do + user.mastodon = '@robin@example.com' + + expect(mastodon).to eq('https://example.com/@robin') + end + end end describe '#gitlab_ui_form_for' do diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb index 428fd5470c3127e38b98e8f3e3e53b28e6265b6b..b443988cde9aa200ec0797ec9eba17b52e137275 100644 --- a/spec/models/user_detail_spec.rb +++ b/spec/models/user_detail_spec.rb @@ -59,6 +59,27 @@ end end + describe '#mastodon' do + it { is_expected.to validate_length_of(:mastodon).is_at_most(500) } + + context 'when mastodon is set' do + let_it_be(:user_detail) { create(:user_detail) } + + it 'accepts a valid mastodon username' do + user_detail.mastodon = '@robin@example.com' + + expect(user_detail).to be_valid + end + + it 'throws an error when mastodon username format is wrong' do + user_detail.mastodon = '@robin' + + expect(user_detail).not_to be_valid + expect(user_detail.errors.full_messages).to match_array([_('Mastodon must contain only a mastodon username.')]) + end + end + end + describe '#location' do it { is_expected.to validate_length_of(:location).is_at_most(500) } end @@ -97,6 +118,7 @@ discord: '1234567890123456789', linkedin: 'linkedin', location: 'location', + mastodon: '@robin@example.com', organization: 'organization', skype: 'skype', twitter: 'twitter', @@ -117,6 +139,7 @@ it_behaves_like 'prevents `nil` value', :discord it_behaves_like 'prevents `nil` value', :linkedin it_behaves_like 'prevents `nil` value', :location + it_behaves_like 'prevents `nil` value', :mastodon it_behaves_like 'prevents `nil` value', :organization it_behaves_like 'prevents `nil` value', :skype it_behaves_like 'prevents `nil` value', :twitter diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 947d83badf6365314a8006dfbb0d738b7ecdccfe..c9da1a31c8686e516dfa233bb8086dd0806535f4 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -113,6 +113,9 @@ it { is_expected.to delegate_method(:linkedin).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:linkedin=).to(:user_detail).with_arguments(:args).allow_nil } + it { is_expected.to delegate_method(:mastodon).to(:user_detail).allow_nil } + it { is_expected.to delegate_method(:mastodon=).to(:user_detail).with_arguments(:args).allow_nil } + it { is_expected.to delegate_method(:twitter).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:twitter=).to(:user_detail).with_arguments(:args).allow_nil }