loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add pronoun privacy option (#6773)

This commit contains UI changes, tests and migrations for a feature
that lets users optionally hide their pronouns from the general
public. This is useful if a person wants to disclose that
information to a smaller set of people on a local instance
belonging to a local community/association.

Co-authored-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Beowulf <beowulf@beocode.eu>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6773
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Panagiotis "Ivory" Vasilopoulos <git@n0toose.net>
Co-committed-by: Panagiotis "Ivory" Vasilopoulos <git@n0toose.net>

+158 -25
+2
models/fixtures/user.yml
··· 45 45 full_name: ' < U<se>r Tw<o > >< ' 46 46 email: user2@example.com 47 47 keep_email_private: true 48 + keep_pronouns_private: true 48 49 email_notifications_preference: enabled 49 50 passwd: ZogKvWdyEx:password 50 51 passwd_hash_algo: dummy ··· 350 351 full_name: User Ten 351 352 email: user10@example.com 352 353 keep_email_private: false 354 + keep_pronouns_private: true 353 355 email_notifications_preference: enabled 354 356 passwd: ZogKvWdyEx:password 355 357 passwd_hash_algo: dummy
+2
models/forgejo_migrations/migrate.go
··· 92 92 NewMigration("Add `hash_blake2b` column to `package_blob` table", AddHashBlake2bToPackageBlob), 93 93 // v27 -> v28 94 94 NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect), 95 + // v28 -> v29 96 + NewMigration("Add pronoun privacy settings to user", AddHidePronounsOptionToUser), 95 97 } 96 98 97 99 // GetCurrentDBVersion returns the current Forgejo database version.
+15
models/forgejo_migrations/v29.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgejo_migrations //nolint:revive 5 + 6 + import "xorm.io/xorm" 7 + 8 + func AddHidePronounsOptionToUser(x *xorm.Engine) error { 9 + type User struct { 10 + ID int64 `xorm:"pk autoincr"` 11 + KeepPronounsPrivate bool `xorm:"NOT NULL DEFAULT false"` 12 + } 13 + 14 + return x.Sync(&User{}) 15 + }
+11
models/user/user.go
··· 154 154 DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"` 155 155 Theme string `xorm:"NOT NULL DEFAULT ''"` 156 156 KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"` 157 + KeepPronounsPrivate bool `xorm:"NOT NULL DEFAULT false"` 157 158 EnableRepoUnitHints bool `xorm:"NOT NULL DEFAULT true"` 158 159 } 159 160 ··· 498 499 return fmt.Sprintf("%s (%s)", trimmedFullName, u.Name) 499 500 } 500 501 return u.Name 502 + } 503 + 504 + // GetPronouns returns an empty string, if the user has set to keep his 505 + // pronouns private from non-logged in users, otherwise the pronouns 506 + // are returned. 507 + func (u *User) GetPronouns(signed bool) string { 508 + if u.KeepPronounsPrivate && !signed { 509 + return "" 510 + } 511 + return u.Pronouns 501 512 } 502 513 503 514 func gitSafeName(name string) string {
+39
models/user/user_test.go
··· 795 795 require.NoError(t, err) 796 796 require.Empty(t, users) 797 797 } 798 + 799 + func TestPronounsPrivacy(t *testing.T) { 800 + require.NoError(t, unittest.PrepareTestDatabase()) 801 + t.Run("EmptyPronounsIfNoneSet", func(t *testing.T) { 802 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 803 + user.Pronouns = "" 804 + user.KeepPronounsPrivate = false 805 + 806 + assert.Equal(t, "", user.GetPronouns(false)) 807 + }) 808 + t.Run("EmptyPronounsIfSetButPrivateAndNotLoggedIn", func(t *testing.T) { 809 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 810 + user.Pronouns = "any" 811 + user.KeepPronounsPrivate = true 812 + 813 + assert.Equal(t, "", user.GetPronouns(false)) 814 + }) 815 + t.Run("ReturnPronounsIfSetAndNotPrivateAndNotLoggedIn", func(t *testing.T) { 816 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 817 + user.Pronouns = "any" 818 + user.KeepPronounsPrivate = false 819 + 820 + assert.Equal(t, "any", user.GetPronouns(false)) 821 + }) 822 + t.Run("ReturnPronounsIfSetAndPrivateAndLoggedIn", func(t *testing.T) { 823 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 824 + user.Pronouns = "any" 825 + user.KeepPronounsPrivate = false 826 + 827 + assert.Equal(t, "any", user.GetPronouns(true)) 828 + }) 829 + t.Run("ReturnPronounsIfSetAndNotPrivateAndLoggedIn", func(t *testing.T) { 830 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 831 + user.Pronouns = "any" 832 + user.KeepPronounsPrivate = true 833 + 834 + assert.Equal(t, "any", user.GetPronouns(true)) 835 + }) 836 + }
+2
modules/structs/user.go
··· 84 84 EnableRepoUnitHints bool `json:"enable_repo_unit_hints"` 85 85 // Privacy 86 86 HideEmail bool `json:"hide_email"` 87 + HidePronouns bool `json:"hide_pronouns"` 87 88 HideActivity bool `json:"hide_activity"` 88 89 } 89 90 ··· 101 102 EnableRepoUnitHints *bool `json:"enable_repo_unit_hints"` 102 103 // Privacy 103 104 HideEmail *bool `json:"hide_email"` 105 + HidePronouns *bool `json:"hide_pronouns"` 104 106 HideActivity *bool `json:"hide_activity"` 105 107 } 106 108
+3 -1
options/locale/locale_en-US.ini
··· 853 853 add_openid_success = The new OpenID address has been added. 854 854 keep_email_private = Hide email address 855 855 keep_email_private_popup = Your email address will not be shown on your profile and will not be the default for commits made via the web interface, like file uploads, edits, and merge commits. Instead, a special address %s can be used to link commits to your account. This option will not affect existing commits. 856 + keep_pronouns_private = Only show pronouns to authenticated users 857 + keep_pronouns_private.description = This will hide your pronouns from visitors that are not logged in. 856 858 openid_desc = OpenID lets you delegate authentication to an external provider. 857 859 858 860 manage_ssh_keys = Manage SSH keys ··· 3935 3937 filepreview.truncated = Preview has been truncated 3936 3938 3937 3939 [translation_meta] 3938 - test = This is a test string. It is not displayed in Forgejo UI but is used for testing purposes. Feel free to enter "ok" to save time (or a fun fact of your choice) to hit that sweet 100% completion mark :) 3940 + test = This is a test string. It is not displayed in Forgejo UI but is used for testing purposes. Feel free to enter "ok" to save time (or a fun fact of your choice) to hit that sweet 100% completion mark :)
+1
routers/api/v1/user/settings.go
··· 63 63 Theme: optional.FromPtr(form.Theme), 64 64 DiffViewStyle: optional.FromPtr(form.DiffViewStyle), 65 65 KeepEmailPrivate: optional.FromPtr(form.HideEmail), 66 + KeepPronounsPrivate: optional.FromPtr(form.HidePronouns), 66 67 KeepActivityPrivate: optional.FromPtr(form.HideActivity), 67 68 EnableRepoUnitHints: optional.FromPtr(form.EnableRepoUnitHints), 68 69 }
+1
routers/common/auth.go
··· 31 31 ctx.Data["SignedUserID"] = ar.Doer.ID 32 32 ctx.Data["IsAdmin"] = ar.Doer.IsAdmin 33 33 } else { 34 + ctx.Data["IsSigned"] = false 34 35 ctx.Data["SignedUserID"] = int64(0) 35 36 } 36 37 return ar, nil
+1
routers/web/user/setting/profile.go
··· 106 106 Location: optional.Some(form.Location), 107 107 Visibility: optional.Some(form.Visibility), 108 108 KeepActivityPrivate: optional.Some(form.KeepActivityPrivate), 109 + KeepPronounsPrivate: optional.Some(form.KeepPronounsPrivate), 109 110 } 110 111 if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { 111 112 ctx.ServerError("UpdateUser", err)
+2 -1
services/convert/user.go
··· 57 57 Created: user.CreatedUnix.AsTime(), 58 58 Restricted: user.IsRestricted, 59 59 Location: user.Location, 60 - Pronouns: user.Pronouns, 60 + Pronouns: user.GetPronouns(signed), 61 61 Website: user.Website, 62 62 Description: user.Description, 63 63 // counter's ··· 97 97 Description: user.Description, 98 98 Theme: user.Theme, 99 99 HideEmail: user.KeepEmailPrivate, 100 + HidePronouns: user.KeepPronounsPrivate, 100 101 HideActivity: user.KeepActivityPrivate, 101 102 DiffViewStyle: user.DiffViewStyle, 102 103 EnableRepoUnitHints: user.EnableRepoUnitHints,
+1
services/forms/user_form.go
··· 224 224 Biography string `binding:"MaxSize(255)"` 225 225 Visibility structs.VisibleType 226 226 KeepActivityPrivate bool 227 + KeepPronounsPrivate bool 227 228 } 228 229 229 230 // Validate validates the fields
+7
services/user/update.go
··· 40 40 SetLastLogin bool 41 41 RepoAdminChangeTeamAccess optional.Option[bool] 42 42 EnableRepoUnitHints optional.Option[bool] 43 + KeepPronounsPrivate optional.Option[bool] 43 44 } 44 45 45 46 func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error { ··· 95 96 u.EnableRepoUnitHints = opts.EnableRepoUnitHints.Value() 96 97 97 98 cols = append(cols, "enable_repo_unit_hints") 99 + } 100 + 101 + if opts.KeepPronounsPrivate.Has() { 102 + u.KeepPronounsPrivate = opts.KeepPronounsPrivate.Value() 103 + 104 + cols = append(cols, "keep_pronouns_private") 98 105 } 99 106 100 107 if opts.AllowGitHook.Has() {
+1 -1
templates/shared/user/profile_big_avatar.tmpl
··· 16 16 </div> 17 17 <div class="content tw-break-anywhere profile-avatar-name"> 18 18 {{if .ContextUser.FullName}}<span class="header text center">{{.ContextUser.FullName}}</span>{{end}} 19 - <span class="username text center">{{.ContextUser.Name}}{{if .ContextUser.Pronouns}} · {{.ContextUser.Pronouns}}{{end}} {{if .IsAdmin}} 19 + <span class="username text center">{{.ContextUser.Name}} {{if .ContextUser.GetPronouns .IsSigned}} · {{.ContextUser.GetPronouns .IsSigned}}{{end}} {{if .IsAdmin}} 20 20 <a class="muted" href="{{AppSubUrl}}/admin/users/{{.ContextUser.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}"> 21 21 {{svg "octicon-gear" 18}} 22 22 </a>
+8
templates/swagger/v1_json.tmpl
··· 27954 27954 "type": "boolean", 27955 27955 "x-go-name": "HideEmail" 27956 27956 }, 27957 + "hide_pronouns": { 27958 + "type": "boolean", 27959 + "x-go-name": "HidePronouns" 27960 + }, 27957 27961 "language": { 27958 27962 "type": "string", 27959 27963 "x-go-name": "Language" ··· 28005 28009 "description": "Privacy", 28006 28010 "type": "boolean", 28007 28011 "x-go-name": "HideEmail" 28012 + }, 28013 + "hide_pronouns": { 28014 + "type": "boolean", 28015 + "x-go-name": "HidePronouns" 28008 28016 }, 28009 28017 "language": { 28010 28018 "type": "string",
+6
templates/user/settings/profile.tmpl
··· 120 120 {{ctx.Locale.Tr "settings.keep_activity_private"}} 121 121 <span class="help">{{ctx.Locale.Tr "settings.keep_activity_private.description" (printf "/%s?tab=activity" .SignedUser.Name)}}</span> 122 122 </label> 123 + 124 + <label> 125 + <input name="keep_pronouns_private" type="checkbox" {{if .SignedUser.KeepPronounsPrivate}}checked{{end}}> 126 + {{ctx.Locale.Tr "settings.keep_pronouns_private"}} 127 + <span class="help">{{ctx.Locale.Tr "settings.keep_pronouns_private.description"}}</span> 128 + </label> 123 129 </fieldset> 124 130 125 131 <button class="ui primary button">{{ctx.Locale.Tr "settings.update_profile"}}</button>
+56 -22
tests/integration/user_test.go
··· 438 438 func TestUserPronouns(t *testing.T) { 439 439 defer tests.PrepareTestEnv(t)() 440 440 441 - session := loginUser(t, "user2") 442 - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) 441 + // user1 is admin, using user2 and user10 respectively instead. 442 + // This is explicitly mentioned here because of the unconventional 443 + // variable naming scheme. 444 + firstUserSession := loginUser(t, "user2") 445 + firstUserToken := getTokenForLoggedInUser(t, firstUserSession, auth_model.AccessTokenScopeWriteUser) 446 + 447 + // This user has the HidePronouns setting enabled. 448 + // Check the fixture! 449 + secondUserSession := loginUser(t, "user10") 450 + secondUserToken := getTokenForLoggedInUser(t, secondUserSession, auth_model.AccessTokenScopeWriteUser) 443 451 444 452 adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 445 453 adminSession := loginUser(t, adminUser.Name) ··· 449 457 t.Run("user", func(t *testing.T) { 450 458 defer tests.PrintCurrentTest(t)() 451 459 452 - req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token) 453 - resp := MakeRequest(t, req, http.StatusOK) 460 + // secondUserToken was chosen arbitrarily and should have no impact. 461 + // See next comment. 462 + req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(secondUserToken) 463 + resp := firstUserSession.MakeRequest(t, req, http.StatusOK) 454 464 455 465 // We check the raw JSON, because we want to test the response, not 456 466 // what it decodes into. Contents doesn't matter, we're testing the ··· 468 478 // what it decodes into. Contents doesn't matter, we're testing the 469 479 // presence only. 470 480 assert.Contains(t, resp.Body.String(), `"pronouns":`) 481 + 482 + req = NewRequest(t, "GET", "/api/v1/users/user10") 483 + resp = MakeRequest(t, req, http.StatusOK) 484 + 485 + // Same deal here. 486 + assert.Contains(t, resp.Body.String(), `"pronouns":`) 471 487 }) 472 488 473 489 t.Run("user/settings", func(t *testing.T) { 474 490 defer tests.PrintCurrentTest(t)() 475 491 476 - // Set pronouns first 492 + // Set pronouns first for user2 477 493 pronouns := "they/them" 478 494 req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{ 479 495 Pronouns: &pronouns, 480 - }).AddTokenAuth(token) 496 + }).AddTokenAuth(firstUserToken) 481 497 resp := MakeRequest(t, req, http.StatusOK) 482 498 483 499 // Verify the response ··· 486 502 assert.Equal(t, pronouns, user.Pronouns) 487 503 488 504 // Verify retrieving the settings again 489 - req = NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token) 505 + req = NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(firstUserToken) 490 506 resp = MakeRequest(t, req, http.StatusOK) 491 507 492 508 DecodeJSON(t, resp, &user) ··· 497 513 defer tests.PrintCurrentTest(t)() 498 514 499 515 // Set the pronouns for user2 500 - pronouns := "she/her" 516 + pronouns := "he/him" 501 517 req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user2", &api.EditUserOption{ 502 518 Pronouns: &pronouns, 503 519 }).AddTokenAuth(adminToken) 504 520 resp := MakeRequest(t, req, http.StatusOK) 505 521 506 522 // Verify the API response 507 - var user *api.User 508 - DecodeJSON(t, resp, &user) 509 - assert.Equal(t, pronouns, user.Pronouns) 523 + var user2 *api.User 524 + DecodeJSON(t, resp, &user2) 525 + assert.Equal(t, pronouns, user2.Pronouns) 510 526 511 - // Verify via user2 too 512 - req = NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token) 527 + // Verify via user2 528 + req = NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(firstUserToken) 529 + resp = MakeRequest(t, req, http.StatusOK) 530 + DecodeJSON(t, resp, &user2) 531 + assert.Equal(t, pronouns, user2.Pronouns) // TODO: This fails for some reason 532 + 533 + // Set the pronouns for user10 534 + pronouns = "he/him" 535 + req = NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user10", &api.EditUserOption{ 536 + Pronouns: &pronouns, 537 + }).AddTokenAuth(adminToken) 538 + resp = MakeRequest(t, req, http.StatusOK) 539 + 540 + // Verify the API response 541 + var user10 *api.User 542 + DecodeJSON(t, resp, &user10) 543 + assert.Equal(t, pronouns, user10.Pronouns) 544 + 545 + // Verify via user10 546 + req = NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(secondUserToken) 513 547 resp = MakeRequest(t, req, http.StatusOK) 514 - DecodeJSON(t, resp, &user) 515 - assert.Equal(t, pronouns, user.Pronouns) 548 + DecodeJSON(t, resp, &user10) 549 + assert.Equal(t, pronouns, user10.Pronouns) 516 550 }) 517 551 }) 518 552 ··· 520 554 defer tests.PrintCurrentTest(t)() 521 555 522 556 // Set the pronouns to a known state via the API 523 - pronouns := "she/her" 557 + pronouns := "they/them" 524 558 req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{ 525 559 Pronouns: &pronouns, 526 - }).AddTokenAuth(token) 560 + }).AddTokenAuth(firstUserToken) 527 561 MakeRequest(t, req, http.StatusOK) 528 562 529 563 t.Run("profile view", func(t *testing.T) { ··· 534 568 htmlDoc := NewHTMLParser(t, resp.Body) 535 569 536 570 userNameAndPronouns := strings.TrimSpace(htmlDoc.Find(".profile-avatar-name .username").Text()) 537 - assert.Contains(t, userNameAndPronouns, pronouns) 571 + assert.NotContains(t, userNameAndPronouns, pronouns) 538 572 }) 539 573 540 574 t.Run("settings", func(t *testing.T) { 541 575 defer tests.PrintCurrentTest(t)() 542 576 543 577 req := NewRequest(t, "GET", "/user/settings") 544 - resp := session.MakeRequest(t, req, http.StatusOK) 578 + resp := firstUserSession.MakeRequest(t, req, http.StatusOK) 545 579 htmlDoc := NewHTMLParser(t, resp.Body) 546 580 547 581 // Check that the field is present ··· 550 584 assert.Equal(t, pronouns, pronounField) 551 585 552 586 // Check that updating the field works 553 - newPronouns := "they/them" 587 + newPronouns := "she/her" 554 588 req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ 555 - "_csrf": GetCSRF(t, session, "/user/settings"), 589 + "_csrf": GetCSRF(t, firstUserSession, "/user/settings"), 556 590 "pronouns": newPronouns, 557 591 }) 558 - session.MakeRequest(t, req, http.StatusSeeOther) 592 + firstUserSession.MakeRequest(t, req, http.StatusSeeOther) 559 593 560 594 user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) 561 595 assert.Equal(t, newPronouns, user2.Pronouns)