From 1b86f174ce99b579df35c32e800551b1fd1df807 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=B5=B5=E6=99=BA=E8=B6=85?= <1012112796@qq.com>
Date: Sat, 18 Apr 2020 21:50:25 +0800
Subject: [PATCH] Add a way to mark Conversation (code comment) resolved
 (#11037)

* Add a way to mark Conversation (code comment) resolved

mark Conversation is a way to mark a Conversation is stale
or be solved. when it's marked as stale, will be hided like
stale. all Pull Request writer , Offical Reviewers and poster
can add or remove Conversation resolved mark.

Signed-off-by: a1012112796 <1012112796@qq.com>

* fix lint

* Apply suggestions from code review

* Add ResolveDoer
* fix ui

Co-Authored-By: Lauris BH <lauris@nix.lv>
Co-Authored-By: 6543 <6543@obermui.de>

* change IsResolved to an function
Add permission check in UpdateResolveConversation

* Apply suggestions from code review

* change return error for permisson check
* add default message for deleted user
* get issue message from comment
* add migration for ``ResolveDoerID`` column

another  change:
* block mark pending review as resolved because it's not necessary

Co-authored-by: guillep2k <18600385+guillep2k@users.noreply.github.com>

* change button color

* resolve button size

* fix code style

* remove unusefull code

Co-authored-by: guillep2k <18600385+guillep2k@users.noreply.github.com>

Co-authored-by: Lauris BH <lauris@nix.lv>
Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: guillep2k <18600385+guillep2k@users.noreply.github.com>
---
 models/issue_comment.go                       | 27 +++++++++
 models/migrations/migrations.go               |  2 +
 models/migrations/v138.go                     | 22 +++++++
 models/review.go                              | 60 +++++++++++++++++++
 options/locale/locale_en-US.ini               |  5 ++
 routers/repo/issue.go                         | 10 ++++
 routers/repo/pull.go                          |  7 +++
 routers/repo/pull_review.go                   | 47 +++++++++++++++
 routers/routes/routes.go                      |  1 +
 templates/repo/diff/box.tmpl                  | 51 +++++++++++++++-
 templates/repo/diff/section_unified.tmpl      | 27 ++++++++-
 .../repo/issue/view_content/comments.tmpl     | 38 ++++++++++--
 web_src/js/index.js                           | 13 ++++
 13 files changed, 301 insertions(+), 9 deletions(-)
 create mode 100644 models/migrations/v138.go

diff --git a/models/issue_comment.go b/models/issue_comment.go
index 2e59a2cb3f1..f7017435d77 100644
--- a/models/issue_comment.go
+++ b/models/issue_comment.go
@@ -122,6 +122,8 @@ type Comment struct {
 	AssigneeID       int64
 	RemovedAssignee  bool
 	Assignee         *User `xorm:"-"`
+	ResolveDoerID    int64
+	ResolveDoer      *User `xorm:"-"`
 	OldTitle         string
 	NewTitle         string
 	OldRef           string
@@ -420,6 +422,26 @@ func (c *Comment) LoadAssigneeUser() error {
 	return nil
 }
 
+// LoadResolveDoer if comment.Type is CommentTypeCode and ResolveDoerID not zero, then load resolveDoer
+func (c *Comment) LoadResolveDoer() (err error) {
+	if c.ResolveDoerID == 0 || c.Type != CommentTypeCode {
+		return nil
+	}
+	c.ResolveDoer, err = getUserByID(x, c.ResolveDoerID)
+	if err != nil {
+		if IsErrUserNotExist(err) {
+			c.ResolveDoer = NewGhostUser()
+			err = nil
+		}
+	}
+	return
+}
+
+// IsResolved check if an code comment is resolved
+func (c *Comment) IsResolved() bool {
+	return c.ResolveDoerID != 0 && c.Type == CommentTypeCode
+}
+
 // LoadDepIssueDetails loads Dependent Issue Details
 func (c *Comment) LoadDepIssueDetails() (err error) {
 	if c.DependentIssueID <= 0 || c.DependentIssue != nil {
@@ -943,7 +965,12 @@ func fetchCodeCommentsByReview(e Engine, issue *Issue, currentUser *User, review
 	if err := e.In("id", ids).Find(&reviews); err != nil {
 		return nil, err
 	}
+
 	for _, comment := range comments {
+		if err := comment.LoadResolveDoer(); err != nil {
+			return nil, err
+		}
+
 		if re, ok := reviews[comment.ReviewID]; ok && re != nil {
 			// If the review is pending only the author can see the comments (except the review is set)
 			if review.ID == 0 {
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index fe72d0f630d..6868aad7b19 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -208,6 +208,8 @@ var migrations = []Migration{
 	NewMigration("Add CommitsAhead and CommitsBehind Column to PullRequest Table", addCommitDivergenceToPulls),
 	// v137 -> v138
 	NewMigration("Add Branch Protection Block Outdated Branch", addBlockOnOutdatedBranch),
+	// v138 -> v139
+	NewMigration("Add ResolveDoerID to Comment table", addResolveDoerIDCommentColumn),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v138.go b/models/migrations/v138.go
new file mode 100644
index 00000000000..2db9b821add
--- /dev/null
+++ b/models/migrations/v138.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 addResolveDoerIDCommentColumn(x *xorm.Engine) error {
+	type Comment struct {
+		ResolveDoerID int64
+	}
+
+	if err := x.Sync2(new(Comment)); err != nil {
+		return fmt.Errorf("Sync2: %v", err)
+	}
+	return nil
+}
diff --git a/models/review.go b/models/review.go
index d6bb77925eb..7f23777c3e1 100644
--- a/models/review.go
+++ b/models/review.go
@@ -5,6 +5,7 @@
 package models
 
 import (
+	"fmt"
 	"strings"
 
 	"code.gitea.io/gitea/modules/timeutil"
@@ -594,3 +595,62 @@ func RemoveRewiewRequest(issue *Issue, reviewer *User, doer *User) (comment *Com
 
 	return comment, sess.Commit()
 }
+
+// MarkConversation Add or remove Conversation mark for a code comment
+func MarkConversation(comment *Comment, doer *User, isResolve bool) (err error) {
+	if comment.Type != CommentTypeCode {
+		return nil
+	}
+
+	if isResolve {
+		if comment.ResolveDoerID != 0 {
+			return nil
+		}
+
+		if _, err = x.Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", doer.ID, comment.ID); err != nil {
+			return err
+		}
+	} else {
+		if comment.ResolveDoerID == 0 {
+			return nil
+		}
+
+		if _, err = x.Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", 0, comment.ID); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// CanMarkConversation  Add or remove Conversation mark for a code comment permission check
+// the PR writer , offfcial reviewer and poster can do it
+func CanMarkConversation(issue *Issue, doer *User) (permResult bool, err error) {
+	if doer == nil || issue == nil {
+		return false, fmt.Errorf("issue or doer is nil")
+	}
+
+	if doer.ID != issue.PosterID {
+		if err = issue.LoadRepo(); err != nil {
+			return false, err
+		}
+
+		perm, err := GetUserRepoPermission(issue.Repo, doer)
+		if err != nil {
+			return false, err
+		}
+
+		permResult = perm.CanAccess(AccessModeWrite, UnitTypePullRequests)
+		if !permResult {
+			if permResult, err = IsOfficialReviewer(issue, doer); err != nil {
+				return false, err
+			}
+		}
+
+		if !permResult {
+			return false, nil
+		}
+	}
+
+	return true, nil
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9653784f91f..cfad41ceb54 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1067,6 +1067,11 @@ issues.review.review = Review
 issues.review.reviewers = Reviewers
 issues.review.show_outdated = Show outdated
 issues.review.hide_outdated = Hide outdated
+issues.review.show_resolved = Show resolved
+issues.review.hide_resolved = Hide resolved
+issues.review.resolve_conversation = Resolve conversation
+issues.review.un_resolve_conversation = Unresolve conversation
+issues.review.resolved_by = marked this conversation as resolved
 issues.assignee.error = Not all assignees was added due to an unexpected error.
 
 pulls.desc = Enable pull requests and code reviews.
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index 9ad379684a5..a7fda4e7692 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -990,6 +990,11 @@ func ViewIssue(ctx *context.Context) {
 				ctx.ServerError("Review.LoadCodeComments", err)
 				return
 			}
+
+			if err = comment.LoadResolveDoer(); err != nil {
+				ctx.ServerError("LoadResolveDoer", err)
+				return
+			}
 		}
 	}
 
@@ -1033,6 +1038,11 @@ func ViewIssue(ctx *context.Context) {
 				ctx.ServerError("IsUserAllowedToMerge", err)
 				return
 			}
+
+			if ctx.Data["CanMarkConversation"], err = models.CanMarkConversation(issue, ctx.User); err != nil {
+				ctx.ServerError("CanMarkConversation", err)
+				return
+			}
 		}
 
 		prUnit, err := repo.GetUnit(models.UnitTypePullRequests)
diff --git a/routers/repo/pull.go b/routers/repo/pull.go
index 63cc39865c0..d23c93d0b65 100644
--- a/routers/repo/pull.go
+++ b/routers/repo/pull.go
@@ -624,6 +624,13 @@ func ViewPullFiles(ctx *context.Context) {
 		return
 	}
 
+	if ctx.IsSigned && ctx.User != nil {
+		if ctx.Data["CanMarkConversation"], err = models.CanMarkConversation(issue, ctx.User); err != nil {
+			ctx.ServerError("CanMarkConversation", err)
+			return
+		}
+	}
+
 	setImageCompareContext(ctx, baseCommit, commit)
 	setPathsCompareContext(ctx, baseCommit, commit, headTarget)
 
diff --git a/routers/repo/pull_review.go b/routers/repo/pull_review.go
index 0f5375dc166..730074b7f3f 100644
--- a/routers/repo/pull_review.go
+++ b/routers/repo/pull_review.go
@@ -61,6 +61,53 @@ func CreateCodeComment(ctx *context.Context, form auth.CodeCommentForm) {
 	ctx.Redirect(comment.HTMLURL())
 }
 
+// UpdateResolveConversation add or remove an Conversation resolved mark
+func UpdateResolveConversation(ctx *context.Context) {
+	action := ctx.Query("action")
+	commentID := ctx.QueryInt64("comment_id")
+
+	comment, err := models.GetCommentByID(commentID)
+	if err != nil {
+		ctx.ServerError("GetIssueByID", err)
+		return
+	}
+
+	if err = comment.LoadIssue(); err != nil {
+		ctx.ServerError("comment.LoadIssue", err)
+		return
+	}
+
+	var permResult bool
+	if permResult, err = models.CanMarkConversation(comment.Issue, ctx.User); err != nil {
+		ctx.ServerError("CanMarkConversation", err)
+		return
+	}
+	if !permResult {
+		ctx.Error(403)
+		return
+	}
+
+	if !comment.Issue.IsPull {
+		ctx.Error(400)
+		return
+	}
+
+	if action == "Resolve" || action == "UnResolve" {
+		err = models.MarkConversation(comment, ctx.User, action == "Resolve")
+		if err != nil {
+			ctx.ServerError("MarkConversation", err)
+			return
+		}
+	} else {
+		ctx.Error(400)
+		return
+	}
+
+	ctx.JSON(200, map[string]interface{}{
+		"ok": true,
+	})
+}
+
 // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
 func SubmitReview(ctx *context.Context, form auth.SubmitReviewForm) {
 	issue := GetActionIssue(ctx)
diff --git a/routers/routes/routes.go b/routers/routes/routes.go
index 2273cb44735..e2514054bfd 100644
--- a/routers/routes/routes.go
+++ b/routers/routes/routes.go
@@ -739,6 +739,7 @@ func RegisterRoutes(m *macaron.Macaron) {
 			m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
 			m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest)
 			m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
+			m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
 		}, context.RepoMustNotBeArchived())
 		m.Group("/comments/:id", func() {
 			m.Post("", repo.UpdateCommentContent)
diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl
index d113f9ecee4..a7893505259 100644
--- a/templates/repo/diff/box.tmpl
+++ b/templates/repo/diff/box.tmpl
@@ -147,32 +147,79 @@
 															{{end}}
 														</tr>
 														{{if gt (len $line.Comments) 0}}
+															{{$resolved := (index $line.Comments 0).IsResolved}}
+															{{$resolveDoer := (index $line.Comments 0).ResolveDoer}}
+															{{$isNotPending := (not (eq (index $line.Comments 0).Review.Type 0))}}
 															<tr class="add-code-comment">
 																<td class="lines-num"></td>
 																<td class="lines-type-marker"></td>
 																<td class="add-comment-left">
+																	{{if and $resolved  (eq $line.GetCommentSide "previous")}}
+																		<div class="ui top attached header">
+																			<span class="ui grey text left"><b>{{$resolveDoer.Name}}</b> {{$.i18n.Tr "repo.issues.review.resolved_by"}}</span>
+																			<button id="show-outdated-{{(index $line.Comments 0).ID}}" data-comment="{{(index $line.Comments 0).ID}}" class="ui compact right labeled button show-outdated">
+																				{{svg "octicon-unfold" 16}}
+																				{{$.i18n.Tr "repo.issues.review.show_resolved"}}
+																			</button>
+																			<button id="hide-outdated-{{(index $line.Comments 0).ID}}" data-comment="{{(index $line.Comments 0).ID}}" class="hide ui compact right labeled button hide-outdated">
+																				{{svg "octicon-fold" 16}}
+																				{{$.i18n.Tr "repo.issues.review.hide_resolved"}}
+																			</button>
+																		</div>
+																	{{end}}
 																	{{if eq $line.GetCommentSide "previous"}}
-																		<div class="field comment-code-cloud">
+																		<div id="code-comments-{{(index  $line.Comments 0).ID}}" class="field comment-code-cloud {{if $resolved}}hide{{end}}">
 																			<div class="comment-list">
 																				<ui class="ui comments">
 																				{{ template "repo/diff/comments" dict "root" $ "comments" $line.Comments}}
 																				</ui>
 																			</div>
 																		{{template "repo/diff/comment_form_datahandler" dict "reply" (index $line.Comments 0).ReviewID "hidden" true "root" $ "comment" (index $line.Comments 0)}}
+																			{{if and $.CanMarkConversation $isNotPending}}
+																				<button class="ui icon tiny button resolve-conversation" data-action="{{if not $resolved}}Resolve{{else}}UnResolve{{end}}" data-comment-id="{{(index $line.Comments 0).ID}}" data-update-url="{{$.RepoLink}}/issues/resolve_conversation" >
+																					{{if $resolved}}
+																						{{$.i18n.Tr "repo.issues.review.un_resolve_conversation"}}
+																					{{else}}
+																						{{$.i18n.Tr "repo.issues.review.resolve_conversation"}}
+																					{{end}}
+																				</button>
+																			{{end}}
 																		</div>
 																	{{end}}
 																</td>
 																<td class="lines-num"></td>
 																<td class="lines-type-marker"></td>
 																<td class="add-comment-right">
+																	{{if and $resolved (eq $line.GetCommentSide "proposed")}}
+																		<div class="ui top attached header">
+																			<span class="ui grey text left"><b>{{$resolveDoer.Name}}</b> {{$.i18n.Tr "repo.issues.review.resolved_by"}}</span>	
+																			<button id="show-outdated-{{(index $line.Comments 0).ID}}" data-comment="{{(index $line.Comments 0).ID}}" class="ui compact right labeled button show-outdated">
+																				{{svg "octicon-unfold" 16}}
+																				{{$.i18n.Tr "repo.issues.review.show_resolved"}}
+																			</button>
+																			<button id="hide-outdated-{{(index $line.Comments 0).ID}}" data-comment="{{(index $line.Comments 0).ID}}" class="hide ui compact right labeled button hide-outdated">
+																				{{svg "octicon-fold" 16}}
+																				{{$.i18n.Tr "repo.issues.review.hide_resolved"}}
+																			</button>	
+																		</div>
+																	{{end}}
 																	{{if eq $line.GetCommentSide "proposed"}}
-																		<div class="field comment-code-cloud">
+																		<div id="code-comments-{{(index  $line.Comments 0).ID}}" class="field comment-code-cloud {{if $resolved}}hide{{end}}">
 																			<div class="comment-list">
 																				<ui class="ui comments">
 																				{{ template "repo/diff/comments" dict "root" $ "comments" $line.Comments}}
 																				</ui>
 																			</div>
 																			{{template "repo/diff/comment_form_datahandler" dict "reply" (index $line.Comments 0).ReviewID "hidden" true "root" $ "comment" (index $line.Comments 0)}}
+																			{{if and $.CanMarkConversation $isNotPending}}
+																				<button class="ui icon tiny button resolve-conversation" data-action="{{if not $resolved}}Resolve{{else}}UnResolve{{end}}" data-comment-id="{{(index $line.Comments 0).ID}}" data-update-url="{{$.RepoLink}}/issues/resolve_conversation" >
+																					{{if $resolved}}
+																						{{$.i18n.Tr "repo.issues.review.un_resolve_conversation"}}
+																					{{else}}
+																						{{$.i18n.Tr "repo.issues.review.resolve_conversation"}}
+																					{{end}}
+																				</button>
+																			{{end}}
 																		</div>
 																	{{end}}
 																</td>
diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl
index c25d3ef079d..4c23c159a25 100644
--- a/templates/repo/diff/section_unified.tmpl
+++ b/templates/repo/diff/section_unified.tmpl
@@ -23,17 +23,42 @@
 			<td class="lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}">{{if and $.root.SignedUserID $line.CanComment $.root.PageIsPullFiles}}<a class="ui green button add-code-comment add-code-comment-{{if $line.RightIdx}}right{{else}}left{{end}}" data-path="{{$file.Name}}" data-side="{{if $line.RightIdx}}right{{else}}left{{end}}" data-idx="{{if $line.RightIdx}}{{$line.RightIdx}}{{else}}{{$line.LeftIdx}}{{end}}">+</a>{{end}}<span class="mono wrap{{if $highlightClass}} language-{{$highlightClass}}{{else}} nohighlight{{end}}">{{$section.GetComputedInlineDiffFor $line}}</span></td>
 		</tr>
 		{{if gt (len $line.Comments) 0}}
+			{{$resolved := (index $line.Comments 0).IsResolved}}
+			{{$resolveDoer := (index $line.Comments 0).ResolveDoer}}
+			{{$isNotPending := (not (eq (index $line.Comments 0).Review.Type 0))}}
 		<tr>
 			<td colspan="2" class="lines-num"></td>
 			<td class="lines-type-marker"></td>
 			<td class="add-comment-left add-comment-right">
-				<div class="field comment-code-cloud">
+				{{if $resolved}}
+					<div class = "ui attached header">
+						<span class="ui grey text left"><b>{{$resolveDoer.Name}}</b> {{$.root.i18n.Tr "repo.issues.review.resolved_by"}}</span>
+						<button id="show-outdated-{{(index $line.Comments 0).ID}}" data-comment="{{(index $line.Comments 0).ID}}" class="ui compact right labeled button show-outdated">
+							{{svg "octicon-unfold" 16}}
+								{{$.root.i18n.Tr "repo.issues.review.show_resolved"}}
+						</button>
+						<button id="hide-outdated-{{(index $line.Comments 0).ID}}" data-comment="{{(index $line.Comments 0).ID}}" class="hide ui compact right labeled button hide-outdated">
+							{{svg "octicon-fold" 16}}
+							{{$.root.i18n.Tr "repo.issues.review.hide_resolved"}}
+						</button>
+					</div>
+				{{end}}
+				<div id="code-comments-{{(index  $line.Comments 0).ID}}" class="field comment-code-cloud {{if $resolved}}hide{{end}}">
 					<div class="comment-list">
 						<ui class="ui comments">
 						{{ template "repo/diff/comments" dict "root" $.root "comments" $line.Comments}}
 						</ui>
 					</div>
 					{{template "repo/diff/comment_form_datahandler" dict "hidden" true "reply" (index $line.Comments 0).ReviewID "root" $.root "comment" (index $line.Comments 0)}}
+					{{if and $.root.CanMarkConversation $isNotPending}}
+						<button class="ui icon tiny button resolve-conversation" data-action="{{if not $resolved}}Resolve{{else}}UnResolve{{end}}" data-comment-id="{{(index $line.Comments 0).ID}}" data-update-url="{{$.root.RepoLink}}/issues/resolve_conversation" >
+							{{if $resolved}}
+								{{$.root.i18n.Tr "repo.issues.review.un_resolve_conversation"}}
+							{{else}}
+								{{$.root.i18n.Tr "repo.issues.review.resolve_conversation"}}
+							{{end}}
+						</button>
+					{{end}}
 				</div>
 			</td>
 		</tr>
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index da1483ec370..27baaed3f25 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -381,6 +381,7 @@
 				</div>
 			</div>
 			{{end}}
+
 			{{if .Review.CodeComments}}
 			<div class="timeline-item event">
 				{{ range $filename, $lines := .Review.CodeComments}}
@@ -388,14 +389,25 @@
 							<div class="ui segments">
 								<div class="ui segment">
 									{{$invalid := (index $comms 0).Invalidated}}
-								{{if $invalid}}
+									{{$resolved := (index $comms 0).IsResolved}}
+									{{$resolveDoer := (index $comms 0).ResolveDoer}}
+									{{$isNotPending := (not (eq (index $comms 0).Review.Type 0))}}
+								{{if or $invalid $resolved}}
 									<button id="show-outdated-{{(index $comms 0).ID}}" data-comment="{{(index $comms 0).ID}}" class="ui compact right labeled button show-outdated">
-										{{svg "octicon-fold" 16}}
-										{{$.i18n.Tr "repo.issues.review.show_outdated"}}
+										{{svg "octicon-unfold" 16}}
+										{{if $invalid }}
+											{{$.i18n.Tr "repo.issues.review.show_outdated"}}
+										{{else}}
+											{{$.i18n.Tr "repo.issues.review.show_resolved"}}
+										{{end}}
 									</button>
 									<button id="hide-outdated-{{(index $comms 0).ID}}" data-comment="{{(index $comms 0).ID}}" class="hide ui compact right labeled button hide-outdated">
 										{{svg "octicon-fold" 16}}
-										{{$.i18n.Tr "repo.issues.review.hide_outdated"}}
+										{{if $invalid}}
+											{{$.i18n.Tr "repo.issues.review.hide_outdated"}}
+										{{else}}
+											{{$.i18n.Tr "repo.issues.review.hide_resolved"}}
+										{{end}}
 									</button>
 								{{end}}
 									<a href="{{(index $comms 0).CodeCommentURL}}" class="file-comment">{{$filename}}</a>
@@ -403,7 +415,7 @@
 								{{$diff := (CommentMustAsDiff (index $comms 0))}}
 								{{if $diff}}
 									{{$file := (index $diff.Files 0)}}
-									<div id="code-preview-{{(index $comms 0).ID}}" class="ui table segment{{if $invalid}} hide{{end}}">
+									<div id="code-preview-{{(index $comms 0).ID}}" class="ui table segment{{if or $invalid $resolved}} hide{{end}}">
 										<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}}">
 											<div class="file-body file-code code-view code-diff code-diff-unified">
 												<table>
@@ -415,7 +427,7 @@
 										</div>
 									</div>
 								{{end}}
-								<div id="code-comments-{{(index $comms 0).ID}}" class="ui segment{{if $invalid}} hide{{end}}">
+								<div id="code-comments-{{(index $comms 0).ID}}" class="ui segment{{if or $invalid $resolved}} hide{{end}}">
 									<div class="ui comments">
 										{{range $comms}}
 											{{ $createdSubStr:= TimeSinceUnix .CreatedUnix $.Lang }}
@@ -445,6 +457,20 @@
 										{{end}}
 									</div>
 									{{template "repo/diff/comment_form_datahandler" dict "hidden" true "reply" (index $comms 0).ReviewID "root" $ "comment" (index $comms 0)}}
+
+									{{if and $.CanMarkConversation $isNotPending}}
+										<button class="ui tiny button resolve-conversation" data-action="{{if not $resolved}}Resolve{{else}}UnResolve{{end}}" data-comment-id="{{(index $comms 0).ID}}" data-update-url="{{$.RepoLink}}/issues/resolve_conversation" >
+											{{if $resolved}}
+												{{$.i18n.Tr "repo.issues.review.un_resolve_conversation"}}
+											{{else}}
+												{{$.i18n.Tr "repo.issues.review.resolve_conversation"}}
+											{{end}}
+										</button>
+									{{end}}
+
+									{{if $resolved}}
+										<span class="ui grey text"><b>{{$resolveDoer.Name}}</b> {{$.i18n.Tr "repo.issues.review.resolved_by"}}</span>
+									{{end}}
 								</div>
 							</div>
 					{{end}}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 2203ab72430..02de3b0068a 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -2566,6 +2566,19 @@ $(document).ready(async () => {
     $(e).click();
   });
 
+  $('.resolve-conversation').on('click', function (e) {
+    e.preventDefault();
+    const id = $(this).data('comment-id');
+    const action = $(this).data('action');
+    const url = $(this).data('update-url');
+
+    $.post(url, {
+      _csrf: csrf,
+      action,
+      comment_id: id,
+    }).then(reload);
+  });
+
   buttonsClickOnEnter();
   searchUsers();
   searchTeams();