From cdc33b29a012e61b42f192d79f9486fa8e21b2ed Mon Sep 17 00:00:00 2001
From: Yarden Shoham <git@yardenshoham.com>
Date: Tue, 2 Jan 2024 03:25:30 +0200
Subject: [PATCH] Add global setting how timestamps should be rendered (#28657)

- Resolves https://github.com/go-gitea/gitea/issues/22493
- Related to https://github.com/go-gitea/gitea/issues/4520

Some admins prefer all timestamps to display the full date instead of
relative time. They can do that now by setting

```ini
[ui]
PREFERRED_TIMESTAMP_TENSE = absolute
```

This setting is set to `mixed` by default, allowing dates to render as
"5 hours ago". Here are some screenshots of the UI with this setting set
to `absolute`:

![image](https://github.com/go-gitea/gitea/assets/20454870/f496457f-6afa-44be-a1e7-249ee5fe0706)

![image](https://github.com/go-gitea/gitea/assets/20454870/c03b14f5-063d-4e13-9780-76ab002d76a9)

![image](https://github.com/go-gitea/gitea/assets/20454870/f4b34e28-1546-4374-9199-c43348844edd)

---------

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
Co-authored-by: delvh <dev.lh@web.de>
---
 custom/conf/app.example.ini                   |  4 +
 .../config-cheat-sheet.en-us.md               |  1 +
 modules/setting/ui.go                         | 87 ++++++++++---------
 modules/timeutil/datetime.go                  | 11 ++-
 modules/timeutil/datetime_test.go             | 10 +--
 modules/timeutil/since.go                     |  4 +
 web_src/js/components/DiffCommitSelector.vue  |  1 +
 7 files changed, 69 insertions(+), 49 deletions(-)

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 08f2e0d63f..d58309f141 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -1244,6 +1244,10 @@ LEVEL = Info
 ;; Change the sort type of the explore pages.
 ;; Default is "recentupdate", but you also have "alphabetically", "reverselastlogin", "newest", "oldest".
 ;EXPLORE_PAGING_DEFAULT_SORT = recentupdate
+;;
+;; The tense all timestamps should be rendered in. Possible values are `absolute` time (i.e. 1970-01-01, 11:59) and `mixed`.
+;; `mixed` means most timestamps are rendered in relative time (i.e. 2 days ago).
+;PREFERRED_TIMESTAMP_TENSE = mixed
 
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md
index beaa8cfb30..e111ff6db6 100644
--- a/docs/content/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/administration/config-cheat-sheet.en-us.md
@@ -231,6 +231,7 @@ The following configuration set `Content-Type: application/vnd.android.package-a
 - `ONLY_SHOW_RELEVANT_REPOS`: **false**: Whether to only show relevant repos on the explore page when no keyword is specified and default sorting is used.
     A repo is considered irrelevant if it's a fork or if it has no metadata (no description, no icon, no topic).
 - `EXPLORE_PAGING_DEFAULT_SORT`: **recentupdate**: Change the sort type of the explore pages. Valid values are "recentupdate", "alphabetically", "reverselastlogin", "newest" and "oldest"
+- `PREFERRED_TIMESTAMP_TENSE`: **mixed**: The tense all timestamps should be rendered in. Possible values are `absolute` time (i.e. 1970-01-01, 11:59) and `mixed`. `mixed` means most timestamps are rendered in relative time (i.e. 2 days ago).
 
 ### UI - Admin (`ui.admin`)
 
diff --git a/modules/setting/ui.go b/modules/setting/ui.go
index f94e6206cd..2f9eef93c3 100644
--- a/modules/setting/ui.go
+++ b/modules/setting/ui.go
@@ -7,33 +7,35 @@ import (
 	"time"
 
 	"code.gitea.io/gitea/modules/container"
+	"code.gitea.io/gitea/modules/log"
 )
 
 // UI settings
 var UI = struct {
-	ExplorePagingNum      int
-	SitemapPagingNum      int
-	IssuePagingNum        int
-	RepoSearchPagingNum   int
-	MembersPagingNum      int
-	FeedMaxCommitNum      int
-	FeedPagingNum         int
-	PackagesPagingNum     int
-	GraphMaxCommitNum     int
-	CodeCommentLines      int
-	ReactionMaxUserNum    int
-	MaxDisplayFileSize    int64
-	ShowUserEmail         bool
-	DefaultShowFullName   bool
-	DefaultTheme          string
-	Themes                []string
-	Reactions             []string
-	ReactionsLookup       container.Set[string] `ini:"-"`
-	CustomEmojis          []string
-	CustomEmojisMap       map[string]string `ini:"-"`
-	SearchRepoDescription bool
-	OnlyShowRelevantRepos bool
-	ExploreDefaultSort    string `ini:"EXPLORE_PAGING_DEFAULT_SORT"`
+	ExplorePagingNum        int
+	SitemapPagingNum        int
+	IssuePagingNum          int
+	RepoSearchPagingNum     int
+	MembersPagingNum        int
+	FeedMaxCommitNum        int
+	FeedPagingNum           int
+	PackagesPagingNum       int
+	GraphMaxCommitNum       int
+	CodeCommentLines        int
+	ReactionMaxUserNum      int
+	MaxDisplayFileSize      int64
+	ShowUserEmail           bool
+	DefaultShowFullName     bool
+	DefaultTheme            string
+	Themes                  []string
+	Reactions               []string
+	ReactionsLookup         container.Set[string] `ini:"-"`
+	CustomEmojis            []string
+	CustomEmojisMap         map[string]string `ini:"-"`
+	SearchRepoDescription   bool
+	OnlyShowRelevantRepos   bool
+	ExploreDefaultSort      string `ini:"EXPLORE_PAGING_DEFAULT_SORT"`
+	PreferredTimestampTense string
 
 	AmbiguousUnicodeDetection bool
 
@@ -67,23 +69,24 @@ var UI = struct {
 		Keywords    string
 	} `ini:"ui.meta"`
 }{
-	ExplorePagingNum:    20,
-	SitemapPagingNum:    20,
-	IssuePagingNum:      20,
-	RepoSearchPagingNum: 20,
-	MembersPagingNum:    20,
-	FeedMaxCommitNum:    5,
-	FeedPagingNum:       20,
-	PackagesPagingNum:   20,
-	GraphMaxCommitNum:   100,
-	CodeCommentLines:    4,
-	ReactionMaxUserNum:  10,
-	MaxDisplayFileSize:  8388608,
-	DefaultTheme:        `gitea-auto`,
-	Themes:              []string{`gitea-auto`, `gitea-light`, `gitea-dark`},
-	Reactions:           []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
-	CustomEmojis:        []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`},
-	CustomEmojisMap:     map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"},
+	ExplorePagingNum:        20,
+	SitemapPagingNum:        20,
+	IssuePagingNum:          20,
+	RepoSearchPagingNum:     20,
+	MembersPagingNum:        20,
+	FeedMaxCommitNum:        5,
+	FeedPagingNum:           20,
+	PackagesPagingNum:       20,
+	GraphMaxCommitNum:       100,
+	CodeCommentLines:        4,
+	ReactionMaxUserNum:      10,
+	MaxDisplayFileSize:      8388608,
+	DefaultTheme:            `gitea-auto`,
+	Themes:                  []string{`gitea-auto`, `gitea-light`, `gitea-dark`},
+	Reactions:               []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
+	CustomEmojis:            []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`},
+	CustomEmojisMap:         map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"},
+	PreferredTimestampTense: "mixed",
 
 	AmbiguousUnicodeDetection: true,
 
@@ -142,6 +145,10 @@ func loadUIFrom(rootCfg ConfigProvider) {
 	UI.DefaultShowFullName = sec.Key("DEFAULT_SHOW_FULL_NAME").MustBool(false)
 	UI.SearchRepoDescription = sec.Key("SEARCH_REPO_DESCRIPTION").MustBool(true)
 
+	if UI.PreferredTimestampTense != "mixed" && UI.PreferredTimestampTense != "absolute" {
+		log.Fatal("ui.PREFERRED_TIMESTAMP_TENSE must be either 'mixed' or 'absolute'")
+	}
+
 	// OnlyShowRelevantRepos=false is important for many private/enterprise instances,
 	// because many private repositories do not have "description/topic", users just want to search by their names.
 	UI.OnlyShowRelevantRepos = sec.Key("ONLY_SHOW_RELEVANT_REPOS").MustBool(false)
diff --git a/modules/timeutil/datetime.go b/modules/timeutil/datetime.go
index 83170b374b..d254a56a74 100644
--- a/modules/timeutil/datetime.go
+++ b/modules/timeutil/datetime.go
@@ -7,11 +7,12 @@ import (
 	"fmt"
 	"html"
 	"html/template"
+	"strings"
 	"time"
 )
 
 // DateTime renders an absolute time HTML element by datetime.
-func DateTime(format string, datetime any) template.HTML {
+func DateTime(format string, datetime any, attrs ...string) template.HTML {
 	if p, ok := datetime.(*time.Time); ok {
 		datetime = *p
 	}
@@ -48,13 +49,15 @@ func DateTime(format string, datetime any) template.HTML {
 		panic(fmt.Sprintf("Unsupported time type %T", datetime))
 	}
 
+	extraAttrs := strings.Join(attrs, " ")
+
 	switch format {
 	case "short":
-		return template.HTML(fmt.Sprintf(`<relative-time format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="%s">%s</relative-time>`, datetimeEscaped, textEscaped))
+		return template.HTML(fmt.Sprintf(`<relative-time %s format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="%s">%s</relative-time>`, extraAttrs, datetimeEscaped, textEscaped))
 	case "long":
-		return template.HTML(fmt.Sprintf(`<relative-time format="datetime" year="numeric" month="long" day="numeric" weekday="" datetime="%s">%s</relative-time>`, datetimeEscaped, textEscaped))
+		return template.HTML(fmt.Sprintf(`<relative-time %s format="datetime" year="numeric" month="long" day="numeric" weekday="" datetime="%s">%s</relative-time>`, extraAttrs, datetimeEscaped, textEscaped))
 	case "full":
-		return template.HTML(fmt.Sprintf(`<relative-time format="datetime" weekday="" year="numeric" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" datetime="%s">%s</relative-time>`, datetimeEscaped, textEscaped))
+		return template.HTML(fmt.Sprintf(`<relative-time %s format="datetime" weekday="" year="numeric" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" datetime="%s">%s</relative-time>`, extraAttrs, datetimeEscaped, textEscaped))
 	}
 	panic(fmt.Sprintf("Unsupported format %s", format))
 }
diff --git a/modules/timeutil/datetime_test.go b/modules/timeutil/datetime_test.go
index f44b7aaae3..387e6274a7 100644
--- a/modules/timeutil/datetime_test.go
+++ b/modules/timeutil/datetime_test.go
@@ -29,17 +29,17 @@ func TestDateTime(t *testing.T) {
 	assert.EqualValues(t, "-", DateTime("short", TimeStamp(0)))
 
 	actual := DateTime("short", "invalid")
-	assert.EqualValues(t, `<relative-time format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="invalid">invalid</relative-time>`, actual)
+	assert.EqualValues(t, `<relative-time  format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="invalid">invalid</relative-time>`, actual)
 
 	actual = DateTime("short", refTimeStr)
-	assert.EqualValues(t, `<relative-time format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="2018-01-01T00:00:00Z">2018-01-01T00:00:00Z</relative-time>`, actual)
+	assert.EqualValues(t, `<relative-time  format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="2018-01-01T00:00:00Z">2018-01-01T00:00:00Z</relative-time>`, actual)
 
 	actual = DateTime("short", refTime)
-	assert.EqualValues(t, `<relative-time format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="2018-01-01T00:00:00Z">2018-01-01</relative-time>`, actual)
+	assert.EqualValues(t, `<relative-time  format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="2018-01-01T00:00:00Z">2018-01-01</relative-time>`, actual)
 
 	actual = DateTime("short", refTimeStamp)
-	assert.EqualValues(t, `<relative-time format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="2017-12-31T19:00:00-05:00">2017-12-31</relative-time>`, actual)
+	assert.EqualValues(t, `<relative-time  format="datetime" year="numeric" month="short" day="numeric" weekday="" datetime="2017-12-31T19:00:00-05:00">2017-12-31</relative-time>`, actual)
 
 	actual = DateTime("full", refTimeStamp)
-	assert.EqualValues(t, `<relative-time format="datetime" weekday="" year="numeric" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" datetime="2017-12-31T19:00:00-05:00">2017-12-31 19:00:00 -05:00</relative-time>`, actual)
+	assert.EqualValues(t, `<relative-time  format="datetime" weekday="" year="numeric" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" datetime="2017-12-31T19:00:00-05:00">2017-12-31 19:00:00 -05:00</relative-time>`, actual)
 }
diff --git a/modules/timeutil/since.go b/modules/timeutil/since.go
index 04fcff54a3..1cb3c4f288 100644
--- a/modules/timeutil/since.go
+++ b/modules/timeutil/since.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 	"time"
 
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/translation"
 )
 
@@ -132,6 +133,9 @@ func timeSinceUnix(then, now time.Time, lang translation.Locale) template.HTML {
 
 // TimeSince renders relative time HTML given a time.Time
 func TimeSince(then time.Time, lang translation.Locale) template.HTML {
+	if setting.UI.PreferredTimestampTense == "absolute" {
+		return DateTime("full", then, `class="time-since"`)
+	}
 	return timeSinceUnix(then, time.Now(), lang)
 }
 
diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue
index 439840c306..54877a18c0 100644
--- a/web_src/js/components/DiffCommitSelector.vue
+++ b/web_src/js/components/DiffCommitSelector.vue
@@ -247,6 +247,7 @@ export default {
             <div class="gt-ellipsis text light-2">
               {{ commit.committer_or_author_name }}
               <span class="text right">
+                <!-- TODO: make this respect the PreferredTimestampTense setting -->
                 <relative-time class="time-since" prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time>
               </span>
             </div>