Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions models/activities/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,21 @@ func (a *Action) GetIssueInfos() []string {
return ret
}

// GetCommentPreview returns the comment body for feed rendering,
// preferring the live Comment.Content over the snapshot in Action.Content.
func (a *Action) GetCommentPreview() string {
if a.Comment != nil {
return a.Comment.Content
}

switch a.OpType {
case ActionPullReviewDismissed:
return a.GetIssueInfos()[2]
default:
return a.GetIssueInfos()[1]
}
}

func (a *Action) getIssueIndex() int64 {
infos := a.GetIssueInfos()
if len(infos) == 0 {
Expand Down
56 changes: 56 additions & 0 deletions models/activities/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package activities_test
import (
"fmt"
"path"
"strings"
"testing"

activities_model "code.gitea.io/gitea/models/activities"
Expand Down Expand Up @@ -157,3 +158,58 @@ func TestDeleteIssueActions(t *testing.T) {
assert.NoError(t, activities_model.DeleteIssueActions(t.Context(), issue.RepoID, issue.ID, issue.Index))
unittest.AssertCount(t, &activities_model.Action{}, 0)
}

func TestGetCommentPreview(t *testing.T) {
t.Run("live comment preferred over snapshot", func(t *testing.T) {
action := &activities_model.Action{
OpType: activities_model.ActionCommentIssue,
Content: "1|stale snapshot",
Comment: &issue_model.Comment{Content: "fresh content"},
}
assert.Equal(t, "fresh content", action.GetCommentPreview())
})

t.Run("snapshot fallback when comment not loaded", func(t *testing.T) {
action := &activities_model.Action{
OpType: activities_model.ActionCommentIssue,
Content: "1|snapshot body",
}
assert.Equal(t, "snapshot body", action.GetCommentPreview())
})

t.Run("dismiss review reads field 2 as fallback", func(t *testing.T) {
action := &activities_model.Action{
OpType: activities_model.ActionPullReviewDismissed,
Content: "5|reviewer|dismiss reason",
}
assert.Equal(t, "dismiss reason", action.GetCommentPreview())
})

t.Run("dismiss review prefers live comment", func(t *testing.T) {
action := &activities_model.Action{
OpType: activities_model.ActionPullReviewDismissed,
Content: "5|reviewer|old reason",
Comment: &issue_model.Comment{Content: "updated reason"},
}
assert.Equal(t, "updated reason", action.GetCommentPreview())
})

t.Run("returns full content for rendering", func(t *testing.T) {
long := strings.Repeat("a", 300)
action := &activities_model.Action{
OpType: activities_model.ActionCommentPull,
Comment: &issue_model.Comment{Content: long},
}
preview := action.GetCommentPreview()
assert.Equal(t, long, preview)
})

t.Run("empty live comment preferred over snapshot", func(t *testing.T) {
action := &activities_model.Action{
OpType: activities_model.ActionCommentIssue,
Content: "1|old content",
Comment: &issue_model.Comment{Content: ""},
}
assert.Empty(t, action.GetCommentPreview())
})
}
39 changes: 39 additions & 0 deletions modules/templates/util_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,45 @@ func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:rev
return output
}

// MaxPreviewLines is the maximum number of lines shown in feed previews.
const MaxPreviewLines = 5

// MaxPreviewChars is the character budget for feed previews.
const MaxPreviewChars = 1000

// TruncateToPreviewLines returns the first MaxPreviewLines lines capped at MaxPreviewChars runes.
func TruncateToPreviewLines(input string) (string, bool) {
truncated := false

if runes := []rune(input); len(runes) > MaxPreviewChars {
input = string(runes[:MaxPreviewChars])
truncated = true
}

lines := strings.SplitN(input, "\n", MaxPreviewLines+1)
if len(lines) > MaxPreviewLines {
truncated = true
}

preview := strings.Join(lines[:min(len(lines), MaxPreviewLines)], "\n")
return preview, truncated
}

// MarkdownToHTMLWithPreviewLimit renders a truncated markdown preview safe for feeds.
func (ut *RenderUtils) MarkdownToHTMLWithPreviewLimit(input string) template.HTML {
preview, truncated := TruncateToPreviewLines(input)

output, err := markdown.RenderString(markup.NewRenderContext(ut.ctx).WithMetas(markup.ComposeSimpleDocumentMetas()), preview)
if err != nil {
log.Error("RenderString: %v", err)
}

if truncated {
output = template.HTML(string(output) + `<span class="tw-text-text-light">…</span>`)
}
return output
}

func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML {
isPullRequest := issue != nil && issue.IsPull
baseLink := fmt.Sprintf("%s/%s", repoLink, util.Iif(isPullRequest, "pulls", "issues"))
Expand Down
65 changes: 65 additions & 0 deletions modules/templates/util_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,71 @@ func TestRenderLabels(t *testing.T) {
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, "")))
}

func TestTruncateToPreviewLines(t *testing.T) {
t.Run("short input unchanged", func(t *testing.T) {
preview, truncated := TruncateToPreviewLines("hello")
assert.Equal(t, "hello", preview)
assert.False(t, truncated)
})

t.Run("exactly MaxPreviewLines", func(t *testing.T) {
input := "1\n2\n3\n4\n5"
preview, truncated := TruncateToPreviewLines(input)
assert.Equal(t, input, preview)
assert.False(t, truncated)
})

t.Run("exceeds MaxPreviewLines", func(t *testing.T) {
input := "1\n2\n3\n4\n5\n6\n7"
preview, truncated := TruncateToPreviewLines(input)
assert.Equal(t, "1\n2\n3\n4\n5", preview)
assert.True(t, truncated)
})

t.Run("single long line capped by MaxPreviewChars", func(t *testing.T) {
input := strings.Repeat("a", MaxPreviewChars+500)
preview, truncated := TruncateToPreviewLines(input)
assert.Len(t, preview, MaxPreviewChars)
assert.True(t, truncated)
})

t.Run("multi-line within char budget", func(t *testing.T) {
input := "short\nlines\nhere"
preview, truncated := TruncateToPreviewLines(input)
assert.Equal(t, input, preview)
assert.False(t, truncated)
})

t.Run("multi-byte runes not broken", func(t *testing.T) {
input := strings.Repeat("\U0001f600", MaxPreviewChars+10)
preview, truncated := TruncateToPreviewLines(input)
assert.Equal(t, strings.Repeat("\U0001f600", MaxPreviewChars), preview)
assert.True(t, truncated)
})
}

func TestMarkdownToHTMLWithPreviewLimit(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()

t.Run("short input has no truncation indicator", func(t *testing.T) {
out := string(newTestRenderUtils(t).MarkdownToHTMLWithPreviewLimit("hello"))
assert.NotContains(t, out, `<span class="tw-text-text-light">`)
assert.Contains(t, out, "hello")
})

t.Run("truncated input has indicator", func(t *testing.T) {
input := "1\n2\n3\n4\n5\n6"
out := string(newTestRenderUtils(t).MarkdownToHTMLWithPreviewLimit(input))
assert.Contains(t, out, `<span class="tw-text-text-light">…</span>`)
})

t.Run("exactly MaxPreviewChars not truncated", func(t *testing.T) {
input := strings.Repeat("\U0001f600", MaxPreviewChars)
out := string(newTestRenderUtils(t).MarkdownToHTMLWithPreviewLimit(input))
assert.NotContains(t, out, `<span class="tw-text-text-light">`)
})
}

func TestUserMention(t *testing.T) {
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user")
Expand Down
24 changes: 20 additions & 4 deletions routers/web/feed/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ func renderCommentMarkdown(ctx *context.Context, act *activities_model.Action, c
return rendered
}

// renderCommentPreview truncates, renders and appends an ellipsis for feed previews.
// Returns empty string when comment is empty.
func renderCommentPreview(ctx *context.Context, act *activities_model.Action, comment string) string {
if len(comment) == 0 {
return ""
}
preview, truncated := templates.TruncateToPreviewLines(comment)
result := string(renderCommentMarkdown(ctx, act, preview))
if truncated {
result += "\n…"
}
return result
}

// feedActionsToFeedItems convert gitea's Action feed to feeds Item
func feedActionsToFeedItems(ctx *context.Context, actions activities_model.ActionList) (items []*feeds.Item, err error) {
renderUtils := templates.NewRenderUtils(ctx)
Expand Down Expand Up @@ -224,16 +238,18 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
content = renderCommentMarkdown(ctx, act, act.GetIssueContent(ctx))
case activities_model.ActionCommentIssue, activities_model.ActionApprovePullRequest, activities_model.ActionRejectPullRequest, activities_model.ActionCommentPull:
desc = act.GetIssueTitle(ctx)
comment := act.GetIssueInfos()[1]
if len(comment) != 0 {
desc += "\n\n" + string(renderCommentMarkdown(ctx, act, comment))
if rendered := renderCommentPreview(ctx, act, act.GetCommentPreview()); len(rendered) != 0 {
desc += "\n\n" + rendered
}
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
desc = act.GetIssueInfos()[1]
case activities_model.ActionCloseIssue, activities_model.ActionReopenIssue, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest:
desc = act.GetIssueTitle(ctx)
case activities_model.ActionPullReviewDismissed:
desc = ctx.Locale.TrString("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2]
desc = ctx.Locale.TrString("action.review_dismissed_reason")
if rendered := renderCommentPreview(ctx, act, act.GetCommentPreview()); len(rendered) != 0 {
desc += "\n\n" + rendered
}
}
}
if len(content) == 0 {
Expand Down
6 changes: 3 additions & 3 deletions templates/user/dashboard/feeds.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,17 @@
<span class="text truncate issue title">{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}</span>
{{else if .GetOpType.InActions "comment_issue" "approve_pull_request" "reject_pull_request" "comment_pull"}}
<a href="{{.GetCommentLink ctx}}" class="text truncate issue title">{{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}}</a>
{{$comment := index .GetIssueInfos 1}}
{{$comment := .GetCommentPreview}}
{{if $comment}}
<div class="render-content markup tw-text-14">{{ctx.RenderUtils.MarkdownToHtml $comment}}</div>
<div class="render-content markup tw-text-14">{{ctx.RenderUtils.MarkdownToHTMLWithPreviewLimit $comment}}</div>
{{end}}
{{else if .GetOpType.InActions "merge_pull_request"}}
<div class="flex-item-body text black">{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}</div>
{{else if .GetOpType.InActions "close_issue" "reopen_issue" "close_pull_request" "reopen_pull_request"}}
<span class="text truncate issue title">{{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}}</span>
{{else if .GetOpType.InActions "pull_review_dismissed"}}
<div class="flex-item-body text black">{{ctx.Locale.Tr "action.review_dismissed_reason"}}</div>
<div class="flex-item-body text black">{{index .GetIssueInfos 2 | ctx.RenderUtils.RenderEmoji}}</div>
<div class="flex-item-body text black">{{.GetCommentPreview | ctx.RenderUtils.RenderEmoji}}</div>
{{end}}
</div>
<div class="flex-item-trailing">
Expand Down