diff --git a/integrations/privateactivity_test.go b/integrations/privateactivity_test.go new file mode 100644 index 0000000000..e9beb7c116 --- /dev/null +++ b/integrations/privateactivity_test.go @@ -0,0 +1,414 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +const privateActivityTestAdmin = "user1" +const privateActivityTestUser = "user2" + +// user3 is an organization so it is not usable here +const privateActivityTestOtherUser = "user4" + +// activity helpers + +func testPrivateActivityDoSomethingForActionEntries(t *testing.T) { + repoBefore := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repoBefore.OwnerID}).(*models.User) + + session := loginUser(t, privateActivityTestUser) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all&token=%s", owner.Name, repoBefore.Name, token) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Body: "test", + Title: "test", + }) + session.MakeRequest(t, req, http.StatusCreated) +} + +// private activity helpers + +func testPrivateActivityHelperEnablePrivateActivity(t *testing.T) { + session := loginUser(t, privateActivityTestUser) + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": privateActivityTestUser, + "email": privateActivityTestUser + "@example.com", + "language": "en-us", + "keep_activity_private": "1", + }) + session.MakeRequest(t, req, http.StatusFound) +} + +func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool { + return htmlDoc.doc.Find(".feeds").Find(".news").Length() > 0 +} + +func testPrivateActivityHelperHasVisibleActivitiesFromSession(t *testing.T, session *TestSession) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc) +} + +func testPrivateActivityHelperHasVisibleActivitiesFromPublic(t *testing.T) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc) +} + +// heatmap UI helpers + +func testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc *HTMLDoc) bool { + return htmlDoc.doc.Find("#user-heatmap").Length() > 0 +} + +func testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t *testing.T, session *TestSession) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc) +} + +func testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t *testing.T, session *TestSession) bool { + req := NewRequest(t, "GET", "/") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc) +} + +func testPrivateActivityHelperHasVisibleHeatmapFromPublic(t *testing.T) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc) +} + +// heatmap API helpers + +func testPrivateActivityHelperHasHeatmapContentFromPublic(t *testing.T) bool { + req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityTestUser) + resp := MakeRequest(t, req, http.StatusOK) + + var items []*models.UserHeatmapData + DecodeJSON(t, resp, &items) + + return len(items) != 0 +} + +func testPrivateActivityHelperHasHeatmapContentFromSession(t *testing.T, session *TestSession) bool { + token := getTokenForLoggedInUser(t, session) + + req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap?token=%s", privateActivityTestUser, token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var items []*models.UserHeatmapData + DecodeJSON(t, resp, &items) + + return len(items) != 0 +} + +// check activity visibility if the visibility is enabled + +func TestPrivateActivityNoVisibleForPublic(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + visible := testPrivateActivityHelperHasVisibleActivitiesFromPublic(t) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityNoVisibleForUserItself(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityNoVisibleForOtherUser(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityNoVisibleForAdmin(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +// check activity visibility if the visibility is disabled + +func TestPrivateActivityYesInvisibleForPublic(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + visible := testPrivateActivityHelperHasVisibleActivitiesFromPublic(t) + + assert.False(t, visible, "user should have no visible activities") +} + +func TestPrivateActivityYesVisibleForUserItself(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityYesInvisibleForOtherUser(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.False(t, visible, "user should have no visible activities") +} + +func TestPrivateActivityYesVisibleForAdmin(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +// check heatmap visibility if the visibility is enabled + +func TestPrivateActivityNoHeatmapVisibleForPublic(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + visible := testPrivateActivityHelperHasVisibleHeatmapFromPublic(t) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForUserItselfAtProfile(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForUserItselfAtDashboard(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForOtherUser(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForAdmin(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +// check heatmap visibility if the visibility is disabled +// this behavior, in special the one for the admin, is +// due to the fact that the heatmap is the same for all viewers; +// otherwise, there is no reason for it + +func TestPrivateActivityYesHeatmapInvisibleForPublic(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + visible := testPrivateActivityHelperHasVisibleHeatmapFromPublic(t) + + assert.False(t, visible, "user should have no visible heatmap") +} + +func TestPrivateActivityYesHeatmapInvisibleForUserItselfAtProfile(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.False(t, visible, "user should have no visible heatmap") +} + +func TestPrivateActivityYesHeatmapInvisibleForUserItselfAtDashboard(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t, session) + + assert.False(t, visible, "user should have no visible heatmap") +} + +func TestPrivateActivityYesHeatmapInvisibleForOtherUser(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.False(t, visible, "user should have no visible heatmap") +} + +func TestPrivateActivityYesHeatmapInvsisibleForAdmin(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.False(t, visible, "user should have no visible heatmap") +} + +// check heatmap api provides content if the visibility is enabled + +func TestPrivateActivityNoHeatmapHasContentForPublic(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + hasContent := testPrivateActivityHelperHasHeatmapContentFromPublic(t) + + assert.True(t, hasContent, "user should have heatmap content") +} + +func TestPrivateActivityNoHeatmapHasContentForUserItself(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "user should have heatmap content") +} + +func TestPrivateActivityNoHeatmapHasContentForOtherUser(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestOtherUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "user should have heatmap content") +} + +func TestPrivateActivityNoHeatmapHasContentForAdmin(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestAdmin) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "user should have heatmap content") +} + +// check heatmap api provides no content if the visibility is disabled +// this should be equal to the hidden heatmap at the UI + +func TestPrivateActivityYesHeatmapHasNoContentForPublic(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + hasContent := testPrivateActivityHelperHasHeatmapContentFromPublic(t) + + assert.False(t, hasContent, "user should have no heatmap content") +} + +func TestPrivateActivityYesHeatmapHasNoContentForUserItself(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.False(t, hasContent, "user should have no heatmap content") +} + +func TestPrivateActivityYesHeatmapHasNoContentForOtherUser(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestOtherUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.False(t, hasContent, "user should have no heatmap content") +} + +func TestPrivateActivityYesHeatmapHasNoContentForAdmin(t *testing.T) { + defer prepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestAdmin) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.False(t, hasContent, "user should have no heatmap content") +} diff --git a/models/action.go b/models/action.go index fd49c6d4ed..59ccdb2d4c 100644 --- a/models/action.go +++ b/models/action.go @@ -319,6 +319,12 @@ func GetFeeds(opts GetFeedsOptions) ([]*Action, error) { cond = cond.And(builder.In("repo_id", AccessibleRepoIDsQuery(opts.Actor))) } + if opts.Actor == nil || !opts.Actor.IsAdmin { + if opts.RequestedUser.KeepActivityPrivate && actorID != opts.RequestedUser.ID { + return make([]*Action, 0), nil + } + } + cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID}) if opts.OnlyPerformedBy { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 869661aee4..432bcffb1b 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -214,6 +214,8 @@ var migrations = []Migration{ NewMigration("prepend refs/heads/ to issue refs", prependRefsHeadsToIssueRefs), // v140 -> v141 NewMigration("Save detected language file size to database instead of percent", fixLanguageStatsToSaveSize), + // v141 -> 142 + NewMigration("Add KeepActivityPrivate to User table", addKeepActivityPrivateUserColumn), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v141.go b/models/migrations/v141.go new file mode 100644 index 0000000000..b5824ecd48 --- /dev/null +++ b/models/migrations/v141.go @@ -0,0 +1,22 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "xorm.io/xorm" +) + +func addKeepActivityPrivateUserColumn(x *xorm.Engine) error { + type User struct { + KeepActivityPrivate bool + } + + if err := x.Sync2(new(User)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + return nil +} diff --git a/models/user.go b/models/user.go index 8875840db7..0ecb1b9a48 100644 --- a/models/user.go +++ b/models/user.go @@ -163,8 +163,9 @@ type User struct { RepoAdminChangeTeamAccess bool `xorm:"NOT NULL DEFAULT false"` // Preferences - DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"` - Theme string `xorm:"NOT NULL DEFAULT ''"` + DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"` + Theme string `xorm:"NOT NULL DEFAULT ''"` + KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"` } // SearchOrganizationsOptions options to filter organizations diff --git a/models/user_heatmap.go b/models/user_heatmap.go index 3d9e0683fc..ce3ec029ca 100644 --- a/models/user_heatmap.go +++ b/models/user_heatmap.go @@ -18,6 +18,11 @@ type UserHeatmapData struct { // GetUserHeatmapDataByUser returns an array of UserHeatmapData func GetUserHeatmapDataByUser(user *User) ([]*UserHeatmapData, error) { hdata := make([]*UserHeatmapData, 0) + + if user.KeepActivityPrivate { + return hdata, nil + } + var groupBy string var groupByName = "timestamp" // We need this extra case because mssql doesn't allow grouping by alias switch { diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index 0c191fbc07..999d4cd74d 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -196,14 +196,15 @@ func (f *AccessTokenForm) Validate(ctx *macaron.Context, errs binding.Errors) bi // UpdateProfileForm form for updating profile type UpdateProfileForm struct { - Name string `binding:"AlphaDashDot;MaxSize(40)"` - FullName string `binding:"MaxSize(100)"` - Email string `binding:"Required;Email;MaxSize(254)"` - KeepEmailPrivate bool - Website string `binding:"ValidUrl;MaxSize(255)"` - Location string `binding:"MaxSize(50)"` - Language string `binding:"Size(5)"` - Description string `binding:"MaxSize(255)"` + Name string `binding:"AlphaDashDot;MaxSize(40)"` + FullName string `binding:"MaxSize(100)"` + Email string `binding:"Required;Email;MaxSize(254)"` + KeepEmailPrivate bool + Website string `binding:"ValidUrl;MaxSize(255)"` + Location string `binding:"MaxSize(50)"` + Language string `binding:"Size(5)"` + Description string `binding:"MaxSize(255)"` + KeepActivityPrivate bool } // Validate validates the fields diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f2e58b95b8..6227ceb2a2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -392,6 +392,7 @@ follow = Follow unfollow = Unfollow heatmap.loading = Loading Heatmap… user_bio = Biography +disabled_public_activity = This user has disabled the public visibility of the activity. form.name_reserved = The username '%s' is reserved. form.name_pattern_not_allowed = The pattern '%s' is not allowed in a username. @@ -430,6 +431,9 @@ continue = Continue cancel = Cancel language = Language ui = Theme +privacy = Privacy +keep_activity_private = Hide the activity from the profile page +keep_activity_private_popup = Makes the activity visible only for you and the admins lookup_avatar_by_mail = Look Up Avatar by Email Address federated_avatar_lookup = Federated Avatar Lookup diff --git a/routers/user/home.go b/routers/user/home.go index 2fc0c60aad..4e5fc3e4df 100644 --- a/routers/user/home.go +++ b/routers/user/home.go @@ -112,7 +112,9 @@ func Dashboard(ctx *context.Context) { ctx.Data["PageIsDashboard"] = true ctx.Data["PageIsNews"] = true ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum - ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap + // no heatmap access for admins; GetUserHeatmapDataByUser ignores the calling user + // so everyone would get the same empty heatmap + ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap && !ctxUser.KeepActivityPrivate ctx.Data["HeatmapUser"] = ctxUser.Name var err error diff --git a/routers/user/profile.go b/routers/user/profile.go index 215dff0084..82fab4ad87 100644 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -93,7 +93,9 @@ func Profile(ctx *context.Context) { ctx.Data["PageIsUserProfile"] = true ctx.Data["Owner"] = ctxUser ctx.Data["OpenIDs"] = openIDs - ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap + // no heatmap access for admins; GetUserHeatmapDataByUser ignores the calling user + // so everyone would get the same empty heatmap + ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap && !ctxUser.KeepActivityPrivate ctx.Data["HeatmapUser"] = ctxUser.Name showPrivate := ctx.IsSigned && (ctx.User.IsAdmin || ctx.User.ID == ctxUser.ID) diff --git a/routers/user/setting/profile.go b/routers/user/setting/profile.go index d6f25f9135..ba9ba2b257 100644 --- a/routers/user/setting/profile.go +++ b/routers/user/setting/profile.go @@ -96,6 +96,7 @@ func ProfilePost(ctx *context.Context, form auth.UpdateProfileForm) { ctx.User.Location = form.Location ctx.User.Language = form.Language ctx.User.Description = form.Description + ctx.User.KeepActivityPrivate = form.KeepActivityPrivate if err := models.UpdateUserSetting(ctx.User); err != nil { if _, ok := err.(models.ErrEmailAlreadyUsed); ok { ctx.Flash.Error(ctx.Tr("form.email_been_used")) diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index e07b4b0dd8..563bc78307 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -104,10 +104,15 @@ {{if eq .TabName "activity"}} - {{if .EnableHeatmap}} - {{template "user/dashboard/heatmap" .}} -
- {{end}} + {{if .Owner.KeepActivityPrivate}} +
+

{{.i18n.Tr "user.disabled_public_activity"}}

+
+ {{end}} + {{if .EnableHeatmap}} + {{template "user/dashboard/heatmap" .}} +
+ {{end}}
{{template "user/dashboard/feeds" .}}
diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl index 995bdfd638..b170c67579 100644 --- a/templates/user/settings/profile.tmpl +++ b/templates/user/settings/profile.tmpl @@ -58,6 +58,13 @@ +
+ +
+ + +
+