mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-21 19:13:38 +02:00
feat: add release reactions and a GitHub-like reaction bar (#38415)
Adds emoji reactions to releases and moves the reaction picker of issues, comments and releases into a GitHub-like reaction bar. - Releases omit 👎 or 😕 - Drafts and tags can't be reacted to - Moved menu to tippy - Added keyboard navigation Fixes: https://github.com/go-gitea/gitea/issues/34268 Fixes: https://github.com/go-gitea/gitea/issues/16676 Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
42 files changed
+508
-313
No files matched your search
@@ -1434,11 +1434,14 @@ LEVEL = Info
|
||||
;; The icon theme for folders (basic/material)
|
||||
;FOLDER_ICON_THEME = basic
|
||||
;;
|
||||
;; All available reactions users can choose on issues/prs and comments.
|
||||
;; All available reactions users can choose on issues, pull requests and comments.
|
||||
;; Values can be emoji alias (:smile:) or a unicode emoji.
|
||||
;; For custom reactions, add a tightly cropped square image to public/assets/img/emoji/reaction_name.png
|
||||
;REACTIONS = +1, -1, laugh, hooray, confused, heart, rocket, eyes
|
||||
;;
|
||||
;; Reactions available for releases
|
||||
;;RELEASE_REACTIONS = +1, laugh, hooray, heart, rocket, eyes
|
||||
;;
|
||||
;; Change the number of users that are displayed in reactions tooltip (triggered by mouse hover).
|
||||
;REACTION_MAX_USER_NUM = 10
|
||||
;;
|
||||
|
||||
@@ -439,6 +439,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(360, "Move branch protection settings to JSON", v29.MoveBranchProtectionConfigToJSON),
|
||||
newMigration(361, "Drop legacy commit_id column from comment", v29.DropCommentCommitID),
|
||||
newMigration(362, "Add reusable_workflow_ref column to action_run_job", v29.AddReusableWorkflowRefToActionRunJob),
|
||||
newMigration(363, "Add release_id to reaction table", v29.AddReleaseIDToReaction),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v29
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/modelmigration/base"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
func AddReleaseIDToReaction(_ctx context.Context, x base.EngineMigration) error {
|
||||
// rows older than v123 can hold NULL, which the unique index doesn't treat as a duplicate of ''
|
||||
if _, err := x.Exec("UPDATE reaction SET original_author = '' WHERE original_author IS NULL"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type Reaction struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
|
||||
UserID int64 `xorm:"UNIQUE(s) NOT NULL"` // first unique column implies INDEX
|
||||
IssueID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) NOT NULL"`
|
||||
CommentID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) INDEX NOT NULL"`
|
||||
ReleaseID int64 `xorm:"UNIQUE(s) INDEX NOT NULL DEFAULT(0)"`
|
||||
OriginalAuthorID int64 `xorm:"UNIQUE(s) NOT NULL DEFAULT(0)"`
|
||||
OriginalAuthor string `xorm:"UNIQUE(s) NOT NULL DEFAULT('')"`
|
||||
Type string `xorm:"UNIQUE(s) NOT NULL"`
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
}
|
||||
return x.Sync(new(Reaction))
|
||||
}
|
||||
@@ -725,10 +725,7 @@ func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository
|
||||
if c.Reactions != nil {
|
||||
return nil
|
||||
}
|
||||
c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{
|
||||
IssueID: c.IssueID,
|
||||
CommentID: c.ID,
|
||||
})
|
||||
c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{CommentID: c.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1210,7 +1207,7 @@ func DeleteComment(ctx context.Context, comment *Comment) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID})
|
||||
return DeleteCommentAllReactions(ctx, comment.ID)
|
||||
}
|
||||
|
||||
// UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id
|
||||
|
||||
@@ -225,13 +225,11 @@ func (issue *Issue) loadCommentsByType(ctx context.Context, tp CommentType) (err
|
||||
return err
|
||||
}
|
||||
|
||||
func (issue *Issue) loadReactions(ctx context.Context) (err error) {
|
||||
func (issue *Issue) loadAllReactions(ctx context.Context) (err error) {
|
||||
if issue.Reactions != nil {
|
||||
return nil
|
||||
}
|
||||
reactions, _, err := FindReactions(ctx, FindReactionsOptions{
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
reactions, _, err := FindReactions(ctx, FindReactionsOptions{IssueIDWithComments: issue.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -340,7 +338,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
return issue.loadReactions(ctx)
|
||||
return issue.loadAllReactions(ctx)
|
||||
}
|
||||
|
||||
// IsPinned returns if a Issue is pinned
|
||||
|
||||
+83
-109
@@ -58,17 +58,20 @@ func (err ErrReactionAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// Reaction represents a reactions on issues and comments.
|
||||
// Reaction represents a reaction on an issue, comment or release.
|
||||
type Reaction struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Type string `xorm:"INDEX UNIQUE(s) NOT NULL"`
|
||||
IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
|
||||
CommentID int64 `xorm:"INDEX UNIQUE(s)"`
|
||||
UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
|
||||
OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"`
|
||||
OriginalAuthor string `xorm:"INDEX UNIQUE(s)"`
|
||||
User *user_model.User `xorm:"-"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
|
||||
UserID int64 `xorm:"UNIQUE(s) NOT NULL"` // first unique column implies INDEX
|
||||
IssueID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) NOT NULL"`
|
||||
CommentID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) INDEX NOT NULL"`
|
||||
ReleaseID int64 `xorm:"UNIQUE(s) INDEX NOT NULL DEFAULT(0)"`
|
||||
OriginalAuthorID int64 `xorm:"UNIQUE(s) NOT NULL DEFAULT(0)"`
|
||||
OriginalAuthor string `xorm:"UNIQUE(s) NOT NULL DEFAULT('')"`
|
||||
Type string `xorm:"UNIQUE(s) NOT NULL"`
|
||||
|
||||
User *user_model.User `xorm:"-"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
}
|
||||
|
||||
// LoadUser load user of reaction
|
||||
@@ -108,53 +111,48 @@ func init() {
|
||||
// FindReactionsOptions describes the conditions to Find reactions
|
||||
type FindReactionsOptions struct {
|
||||
db.ListOptions
|
||||
IssueID int64
|
||||
CommentID int64
|
||||
UserID int64
|
||||
Reaction string
|
||||
IssueIDOnly int64
|
||||
IssueIDWithComments int64
|
||||
CommentID int64
|
||||
ReleaseID int64
|
||||
UserID int64
|
||||
}
|
||||
|
||||
func (opts *FindReactionsOptions) toConds() builder.Cond {
|
||||
// If Issue ID is set add to Query
|
||||
cond := builder.NewCond()
|
||||
if opts.IssueID > 0 {
|
||||
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID})
|
||||
if opts.IssueIDOnly > 0 {
|
||||
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueIDOnly}).And(builder.Expr("reaction.comment_id = 0"))
|
||||
}
|
||||
if opts.IssueIDWithComments > 0 {
|
||||
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueIDWithComments})
|
||||
}
|
||||
// If CommentID is > 0 add to Query
|
||||
// If it is 0 Query ignore CommentID to select
|
||||
// If it is -1 it explicit search of Issue Reactions where CommentID = 0
|
||||
if opts.CommentID > 0 {
|
||||
cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID})
|
||||
} else if opts.CommentID == -1 {
|
||||
cond = cond.And(builder.Eq{"reaction.comment_id": 0})
|
||||
}
|
||||
if opts.ReleaseID > 0 {
|
||||
cond = cond.And(builder.Eq{"reaction.release_id": opts.ReleaseID})
|
||||
}
|
||||
if opts.UserID > 0 {
|
||||
cond = cond.And(builder.Eq{
|
||||
"reaction.user_id": opts.UserID,
|
||||
"reaction.original_author_id": 0,
|
||||
})
|
||||
cond = cond.And(builder.Eq{"reaction.user_id": opts.UserID, "reaction.original_author_id": 0})
|
||||
}
|
||||
if opts.Reaction != "" {
|
||||
cond = cond.And(builder.Eq{"reaction.type": opts.Reaction})
|
||||
if !cond.IsValid() {
|
||||
setting.PanicInDevOrTesting("FindReactionsOptions.toConds: no conditions specified")
|
||||
cond = cond.And(builder.Expr("1=0"))
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
// FindCommentReactions returns a ReactionList of all reactions from an comment
|
||||
func FindCommentReactions(ctx context.Context, issueID, commentID int64) (ReactionList, int64, error) {
|
||||
return FindReactions(ctx, FindReactionsOptions{
|
||||
IssueID: issueID,
|
||||
CommentID: commentID,
|
||||
})
|
||||
// FindCommentReactions returns a ReactionList of all reactions from a comment
|
||||
func FindCommentReactions(ctx context.Context, commentID int64) (ReactionList, int64, error) {
|
||||
return FindReactions(ctx, FindReactionsOptions{CommentID: commentID})
|
||||
}
|
||||
|
||||
// FindIssueReactions returns a ReactionList of all reactions from an issue
|
||||
func FindIssueReactions(ctx context.Context, issueID int64, listOptions db.ListOptions) (ReactionList, int64, error) {
|
||||
return FindReactions(ctx, FindReactionsOptions{
|
||||
ListOptions: listOptions,
|
||||
IssueID: issueID,
|
||||
CommentID: -1,
|
||||
IssueIDOnly: issueID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -177,37 +175,14 @@ func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList
|
||||
return reactions, count, err
|
||||
}
|
||||
|
||||
func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
|
||||
reaction := &Reaction{
|
||||
Type: opts.Type,
|
||||
UserID: opts.DoerID,
|
||||
IssueID: opts.IssueID,
|
||||
CommentID: opts.CommentID,
|
||||
}
|
||||
findOpts := FindReactionsOptions{
|
||||
IssueID: opts.IssueID,
|
||||
CommentID: opts.CommentID,
|
||||
Reaction: opts.Type,
|
||||
UserID: opts.DoerID,
|
||||
}
|
||||
if findOpts.CommentID == 0 {
|
||||
// explicit search of Issue Reactions where CommentID = 0
|
||||
findOpts.CommentID = -1
|
||||
}
|
||||
|
||||
existingR, _, err := FindReactions(ctx, findOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(existingR) > 0 {
|
||||
return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type}
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, reaction); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reaction, nil
|
||||
func FindReleaseReactions(ctx context.Context, releaseIDs ...int64) (ReactionList, error) {
|
||||
reactions := make(ReactionList, 0, 10)
|
||||
err := db.GetEngine(ctx).
|
||||
In("release_id", releaseIDs).
|
||||
In("`type`", setting.UI.ReleaseReactions).
|
||||
Asc("created_unix", "id").
|
||||
Find(&reactions)
|
||||
return reactions, err
|
||||
}
|
||||
|
||||
// ReactionOptions defines options for creating or deleting reactions
|
||||
@@ -216,56 +191,63 @@ type ReactionOptions struct {
|
||||
DoerID int64
|
||||
IssueID int64
|
||||
CommentID int64
|
||||
ReleaseID int64
|
||||
}
|
||||
|
||||
// CreateReaction creates reaction for issue or comment.
|
||||
func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
|
||||
if !setting.UI.ReactionsLookup.Contains(opts.Type) {
|
||||
// CreateReaction creates reaction for issue, comment or release.
|
||||
func CreateReaction(ctx context.Context, allowed container.Set[string], opts *ReactionOptions) (*Reaction, error) {
|
||||
if !allowed.Contains(opts.Type) {
|
||||
return nil, ErrForbiddenIssueReaction{opts.Type}
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Reaction, error) {
|
||||
return createReaction(ctx, opts)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteReaction deletes reaction for issue or comment.
|
||||
func DeleteReaction(ctx context.Context, opts *ReactionOptions) error {
|
||||
reaction := &Reaction{
|
||||
Type: opts.Type,
|
||||
UserID: opts.DoerID,
|
||||
IssueID: opts.IssueID,
|
||||
CommentID: opts.CommentID,
|
||||
ReleaseID: opts.ReleaseID,
|
||||
}
|
||||
|
||||
sess := db.GetEngine(ctx).Where("original_author_id = 0")
|
||||
if opts.CommentID == -1 {
|
||||
reaction.CommentID = 0
|
||||
sess.MustCols("comment_id")
|
||||
err := db.Insert(ctx, reaction)
|
||||
if err != nil {
|
||||
if has, _ := db.GetEngine(ctx).Get(reaction); has {
|
||||
return reaction, ErrReactionAlreadyExist{Reaction: opts.Type}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return reaction, nil
|
||||
}
|
||||
|
||||
_, err := sess.Delete(reaction)
|
||||
func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error {
|
||||
// here and below: the migration reactions have unrelated user_id (might be the doer?)
|
||||
// so skip the deletion if the reaction is from an external user (original_author_id != 0)
|
||||
sess := db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0 AND user_id = ? AND original_author_id = 0 AND `type` = ?", issueID, doerID, content)
|
||||
_, err := sess.Delete(&Reaction{})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteIssueReaction deletes a reaction on issue.
|
||||
func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error {
|
||||
return DeleteReaction(ctx, &ReactionOptions{
|
||||
Type: content,
|
||||
DoerID: doerID,
|
||||
IssueID: issueID,
|
||||
CommentID: -1,
|
||||
})
|
||||
func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error {
|
||||
sess := db.GetEngine(ctx).Where("issue_id = ? AND comment_id = ? AND user_id = ? AND original_author_id = 0 AND `type` = ?", issueID, commentID, doerID, content)
|
||||
_, err := sess.Delete(&Reaction{})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCommentReaction deletes a reaction on comment.
|
||||
func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error {
|
||||
return DeleteReaction(ctx, &ReactionOptions{
|
||||
Type: content,
|
||||
DoerID: doerID,
|
||||
IssueID: issueID,
|
||||
CommentID: commentID,
|
||||
})
|
||||
func DeleteCommentAllReactions(ctx context.Context, commentID int64) error {
|
||||
sess := db.GetEngine(ctx).Where("comment_id = ?", commentID)
|
||||
_, err := sess.Delete(&Reaction{})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteUserReactions(ctx context.Context, userID int64) error {
|
||||
sess := db.GetEngine(ctx).Where("user_id = ?", userID)
|
||||
_, err := sess.Delete(&Reaction{})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteReleaseReaction(ctx context.Context, doerID, releaseID int64, content string) error {
|
||||
sess := db.GetEngine(ctx).Where("release_id = ? AND user_id = ? AND `type` = ?", releaseID, doerID, content)
|
||||
_, err := sess.Delete(&Reaction{})
|
||||
return err
|
||||
}
|
||||
|
||||
// ReactionList represents list of reactions
|
||||
@@ -302,23 +284,15 @@ func (list ReactionList) getUserIDs() []int64 {
|
||||
})
|
||||
}
|
||||
|
||||
func valuesUser(m map[int64]*user_model.User) []*user_model.User {
|
||||
values := make([]*user_model.User, 0, len(m))
|
||||
for _, v := range m {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// newMigrationOriginalUser creates and returns a fake user for external user
|
||||
func newMigrationOriginalUser(name string) *user_model.User {
|
||||
return &user_model.User{ID: 0, Name: name, LowerName: strings.ToLower(name)}
|
||||
}
|
||||
|
||||
// LoadUsers loads reactions' all users
|
||||
func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) {
|
||||
func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) (map[int64]*user_model.User, error) {
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
return map[int64]*user_model.User{}, nil
|
||||
}
|
||||
|
||||
userIDs := list.getUserIDs()
|
||||
@@ -339,7 +313,7 @@ func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Reposit
|
||||
reaction.User = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
return valuesUser(userMaps), nil
|
||||
return userMaps, nil
|
||||
}
|
||||
|
||||
// GetFirstUsers returns first reacted user display names separated by comma
|
||||
|
||||
@@ -30,7 +30,9 @@ var UI = struct {
|
||||
FileIconTheme string
|
||||
FolderIconTheme string
|
||||
Reactions []string
|
||||
ReleaseReactions []string
|
||||
ReactionsLookup container.Set[string] `ini:"-"`
|
||||
ReleaseReactionsLookup container.Set[string] `ini:"-"`
|
||||
CustomEmojis []string
|
||||
CustomEmojisMap map[string]string `ini:"-"`
|
||||
EnabledEmojis []string
|
||||
@@ -98,6 +100,7 @@ var UI = struct {
|
||||
FileIconTheme: `material`,
|
||||
FolderIconTheme: `basic`,
|
||||
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
|
||||
ReleaseReactions: []string{`+1`, `laugh`, `hooray`, `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:"},
|
||||
ExploreDefaultSort: "recentupdate",
|
||||
@@ -170,10 +173,9 @@ func loadUIFrom(rootCfg ConfigProvider) {
|
||||
// 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)
|
||||
|
||||
UI.ReactionsLookup = make(container.Set[string])
|
||||
for _, reaction := range UI.Reactions {
|
||||
UI.ReactionsLookup.Add(reaction)
|
||||
}
|
||||
UI.ReactionsLookup = container.SetOf(UI.Reactions...)
|
||||
UI.ReleaseReactionsLookup = container.SetOf(UI.ReleaseReactions...)
|
||||
|
||||
UI.CustomEmojisMap = make(map[string]string)
|
||||
for _, emoji := range UI.CustomEmojis {
|
||||
UI.CustomEmojisMap[emoji] = ":" + emoji + ":"
|
||||
|
||||
@@ -93,9 +93,6 @@ func newFuncMapWebPage() template.FuncMap {
|
||||
"ShowFooterPoweredBy": func() bool {
|
||||
return setting.Other.ShowFooterPoweredBy
|
||||
},
|
||||
"AllowedReactions": func() []string {
|
||||
return setting.UI.Reactions
|
||||
},
|
||||
"MetaAuthor": func() string {
|
||||
return setting.UI.Meta.Author
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ func GetIssueCommentReactions(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
reactions, _, err := issues_model.FindCommentReactions(ctx, comment.IssueID, comment.ID)
|
||||
reactions, _, err := issues_model.FindCommentReactions(ctx, comment.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
|
||||
@@ -669,7 +669,6 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex
|
||||
}
|
||||
|
||||
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypePullRequests)
|
||||
|
||||
+10
-14
@@ -25,6 +25,7 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -45,7 +46,7 @@ const (
|
||||
tplIssueView templates.TplName = "repo/issue/view"
|
||||
|
||||
tplPullMergeBox templates.TplName = "repo/issue/view_content/pull_merge_box"
|
||||
tplReactions templates.TplName = "repo/issue/view_content/reactions"
|
||||
tplReactions templates.TplName = "shared/reactions"
|
||||
|
||||
issueTemplateKey = "IssueTemplate"
|
||||
issueTemplateTitleKey = "IssueTemplateTitle"
|
||||
@@ -536,25 +537,20 @@ func ChangeIssueReaction(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(issue.Reactions) == 0 {
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"empty": true,
|
||||
"html": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
renderReactions(ctx, fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index), issue.Reactions, setting.UI.Reactions)
|
||||
}
|
||||
|
||||
func renderReactions(ctx *context.Context, actionURL string, reactions issues_model.ReactionList, allowedReactions []string) {
|
||||
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
|
||||
"ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index),
|
||||
"Reactions": issue.Reactions.GroupByType(),
|
||||
"ActionURL": actionURL,
|
||||
"Reactions": reactions.GroupByType(),
|
||||
"AllowedReactions": allowedReactions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("ChangeIssueReaction.HTMLString", err)
|
||||
ctx.ServerError("RenderToHTML", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"html": html,
|
||||
})
|
||||
ctx.JSON(http.StatusOK, map[string]any{"html": html})
|
||||
}
|
||||
|
||||
// GetIssueAttachments returns attachments for the issue
|
||||
|
||||
@@ -300,25 +300,7 @@ func ChangeCommentReaction(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(comment.Reactions) == 0 {
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"empty": true,
|
||||
"html": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
|
||||
"ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID),
|
||||
"Reactions": comment.Reactions.GroupByType(),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("ChangeCommentReaction.HTMLString", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"html": html,
|
||||
})
|
||||
renderReactions(ctx, fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), comment.Reactions, setting.UI.Reactions)
|
||||
}
|
||||
|
||||
// GetCommentAttachments returns attachments for the comment
|
||||
|
||||
@@ -111,7 +111,6 @@ func NewIssue(ctx *context.Context) {
|
||||
|
||||
isProjectsEnabled := ctx.Repo.Permission.CanRead(unit.TypeProjects)
|
||||
ctx.Data["IsProjectsEnabled"] = isProjectsEnabled
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
pageMetaData := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, false)
|
||||
|
||||
@@ -362,7 +362,7 @@ func ViewIssue(ctx *context.Context) {
|
||||
}
|
||||
|
||||
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects)
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
ctx.Data["AllowedIssueReactions"] = setting.UI.Reactions
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
if err := issue.LoadAttributes(ctx); err != nil {
|
||||
|
||||
@@ -908,12 +908,13 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
|
||||
ctx.Data["IsIssuePoster"] = ctx.Doer != nil && issue.IsPoster(ctx.Doer.ID)
|
||||
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
|
||||
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
// For files changed page
|
||||
PrepareBranchList(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["AllowedIssueReactions"] = setting.UI.Reactions
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
|
||||
|
||||
@@ -55,7 +55,6 @@ func RenderNewCodeCommentForm(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
ctx.Data["AfterCommitID"] = pullHeadCommitID
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
ctx.HTML(http.StatusOK, tplNewComment)
|
||||
}
|
||||
@@ -186,7 +185,7 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
ctx.Data["AllowedIssueReactions"] = setting.UI.Reactions
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
ctx.Data["comments"] = comments
|
||||
|
||||
+85
-13
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
@@ -73,6 +74,7 @@ type ReleaseInfo struct {
|
||||
Release *repo_model.Release
|
||||
CommitStatus *git_model.CommitStatus
|
||||
CommitStatuses []*git_model.CommitStatus
|
||||
Reactions issues_model.ReactionList
|
||||
}
|
||||
|
||||
func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) ([]*ReleaseInfo, error) {
|
||||
@@ -81,17 +83,31 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releaseIDs := make([]int64, 0, len(releases))
|
||||
for _, release := range releases {
|
||||
release.Repo = ctx.Repo.Repository
|
||||
releaseIDs = append(releaseIDs, release.ID)
|
||||
}
|
||||
|
||||
if err = repo_model.GetReleaseAttachments(ctx, releases...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reactions, err := issues_model.FindReleaseReactions(ctx, releaseIDs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cacheUsers, err := reactions.LoadUsers(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reactionsByRelease := make(map[int64]issues_model.ReactionList)
|
||||
for _, reaction := range reactions {
|
||||
reactionsByRelease[reaction.ReleaseID] = append(reactionsByRelease[reaction.ReleaseID], reaction)
|
||||
}
|
||||
|
||||
// Temporary cache commits count of used branches to speed up.
|
||||
countCache := make(map[string]int64)
|
||||
cacheUsers := make(map[int64]*user_model.User)
|
||||
if ctx.Doer != nil {
|
||||
cacheUsers[ctx.Doer.ID] = ctx.Doer
|
||||
}
|
||||
@@ -138,7 +154,8 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
|
||||
}
|
||||
|
||||
info := &ReleaseInfo{
|
||||
Release: r,
|
||||
Release: r,
|
||||
Reactions: reactionsByRelease[r.ID],
|
||||
}
|
||||
|
||||
if canReadCode {
|
||||
@@ -154,10 +171,22 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
|
||||
return releaseInfos, nil
|
||||
}
|
||||
|
||||
// Releases render releases list page
|
||||
func Releases(ctx *context.Context) {
|
||||
func prepareReleasePageCommon(ctx *context.Context) (ret struct {
|
||||
writeAccess bool
|
||||
},
|
||||
) {
|
||||
ctx.Data["PageIsReleaseList"] = true
|
||||
ctx.Data["Title"] = ctx.Tr("repo.release.releases")
|
||||
ctx.Data["AllowedReleaseReactions"] = release_service.AllowedReleaseReactions()
|
||||
|
||||
ret.writeAccess = ctx.Repo.Permission.CanWrite(unit.TypeReleases)
|
||||
ctx.Data["CanCreateRelease"] = ret.writeAccess && !ctx.Repo.Repository.IsArchived
|
||||
return ret
|
||||
}
|
||||
|
||||
// Releases render releases list page
|
||||
func Releases(ctx *context.Context) {
|
||||
preparedData := prepareReleasePageCommon(ctx)
|
||||
|
||||
listOptions := db.ListOptions{
|
||||
Page: ctx.FormInt("page"),
|
||||
@@ -170,13 +199,10 @@ func Releases(ctx *context.Context) {
|
||||
listOptions.PageSize = setting.API.MaxResponseItems
|
||||
}
|
||||
|
||||
writeAccess := ctx.Repo.Permission.CanWrite(unit.TypeReleases)
|
||||
ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived
|
||||
|
||||
releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{
|
||||
ListOptions: listOptions,
|
||||
// only show draft releases for users who can write, read-only users shouldn't see draft releases.
|
||||
IncludeDrafts: writeAccess,
|
||||
IncludeDrafts: preparedData.writeAccess,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -275,17 +301,14 @@ func releasesOrTagsFeed(ctx *context.Context, isReleasesOnly bool, formatType st
|
||||
|
||||
// SingleRelease renders a single release's page
|
||||
func SingleRelease(ctx *context.Context) {
|
||||
ctx.Data["PageIsReleaseList"] = true
|
||||
|
||||
writeAccess := ctx.Repo.Permission.CanWrite(unit.TypeReleases)
|
||||
ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived
|
||||
preparedData := prepareReleasePageCommon(ctx)
|
||||
|
||||
releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 1},
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
TagNames: []string{ctx.PathParam("*")},
|
||||
// only show draft releases for users who can write, read-only users shouldn't see draft releases.
|
||||
IncludeDrafts: writeAccess,
|
||||
IncludeDrafts: preparedData.writeAccess,
|
||||
IncludeTags: true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -681,3 +704,52 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) {
|
||||
|
||||
redirect()
|
||||
}
|
||||
|
||||
func ChangeReleaseReaction(ctx *context.Context) {
|
||||
form := context.GetFetchActionForm[*forms.ReactionForm](ctx)
|
||||
if form == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetReleaseForRepoByID", err)
|
||||
return
|
||||
}
|
||||
if rel.IsTag || rel.IsDraft {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
rel.Repo = ctx.Repo.Repository
|
||||
|
||||
switch ctx.PathParam("action") {
|
||||
case "react":
|
||||
_, err = release_service.CreateReleaseReaction(ctx, ctx.Doer, rel, form.Content)
|
||||
if errors.Is(err, util.ErrPermissionDenied) {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, util.ErrAlreadyExist) {
|
||||
ctx.ServerError("CreateReleaseReaction", err)
|
||||
return
|
||||
}
|
||||
case "unreact":
|
||||
if err = issues_model.DeleteReleaseReaction(ctx, ctx.Doer.ID, rel.ID, form.Content); err != nil {
|
||||
ctx.ServerError("DeleteReleaseReaction", err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
reactions, err := issues_model.FindReleaseReactions(ctx, rel.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindReleaseReactions", err)
|
||||
return
|
||||
}
|
||||
if _, err = reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
|
||||
ctx.ServerError("LoadUsers", err)
|
||||
return
|
||||
}
|
||||
renderReactions(ctx, fmt.Sprintf("%s/releases/%d/reactions", ctx.Repo.RepoLink, rel.ID), reactions, release_service.AllowedReleaseReactions())
|
||||
}
|
||||
+3
-2
@@ -224,13 +224,13 @@ func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.C
|
||||
}
|
||||
|
||||
if options.SignInRequired && !ctx.IsSigned {
|
||||
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
|
||||
ctx.RedirectForLogin(middleware.RedirectLinkUserLogin(ctx.Req))
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to log in page if auto-signin info is provided and has not signed in.
|
||||
if !options.SignOutRequired && !ctx.IsSigned && ctx.GetSiteCookie(setting.CookieRememberName) != "" {
|
||||
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
|
||||
ctx.RedirectForLogin(middleware.RedirectLinkUserLogin(ctx.Req))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1503,6 +1503,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
}, ctxDataSet(reqctx.ContextData{"EnableFeed": setting.Other.EnableFeed}))
|
||||
m.Get("/releases/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
|
||||
m.Get("/releases/download/{vTag}/{fileName}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.RedirectDownload)
|
||||
m.Post("/releases/{id}/reactions/{action}", reqSignIn, context.RepoMustNotBeArchived(), repo.ChangeReleaseReaction)
|
||||
m.Group("/releases", func() {
|
||||
m.Get("/new", repo.NewRelease)
|
||||
m.Post("/new", web.Bind[*forms.NewReleaseForm](), repo.NewReleasePost)
|
||||
|
||||
@@ -148,16 +148,26 @@ func (b *Base) PlainText(status int, text string) {
|
||||
|
||||
// Redirect redirects the request
|
||||
func (b *Base) Redirect(location string, status ...int) {
|
||||
code := util.OptionalArg(status, http.StatusSeeOther)
|
||||
// In case the request is made by "fetch-action" module, make JS redirect to the new location
|
||||
// Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected.
|
||||
if httplib.IsGiteaFetchActionRequest(b.Req) {
|
||||
b.JSON(http.StatusOK, map[string]any{"redirect": location})
|
||||
return
|
||||
}
|
||||
code := util.OptionalArg(status, http.StatusSeeOther)
|
||||
http.Redirect(b.Resp, b.Req, location, code)
|
||||
}
|
||||
|
||||
func (b *Base) RedirectForLogin(location string) {
|
||||
if httplib.IsGiteaFetchActionRequest(b.Req) {
|
||||
// let fetch-action handles the redirect automatically
|
||||
b.JSON(http.StatusUnauthorized, map[string]any{"redirect": location})
|
||||
} else {
|
||||
// keep the old behaivor, use 303 to redirect to login page
|
||||
http.Redirect(b.Resp, b.Req, location, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
type ServeHeaderOptions = httplib.ServeHeaderOptions
|
||||
|
||||
func (b *Base) SetServeHeaders(opts ServeHeaderOptions) {
|
||||
|
||||
@@ -104,6 +104,7 @@ type uploadOptions struct {
|
||||
|
||||
// AddUploadContext renders template values for dropzone
|
||||
func AddUploadContext(ctx *context.Context, uploadType string) {
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
switch uploadType {
|
||||
case "release":
|
||||
ctx.Data["UploadOptions"] = uploadOptions{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// CreateIssueReaction creates a reaction on an issue.
|
||||
@@ -20,7 +21,7 @@ func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issu
|
||||
return nil, user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
|
||||
return issues_model.CreateReaction(ctx, setting.UI.ReactionsLookup, &issues_model.ReactionOptions{
|
||||
Type: content,
|
||||
DoerID: doer.ID,
|
||||
IssueID: issue.ID,
|
||||
@@ -41,7 +42,7 @@ func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *
|
||||
return nil, user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
|
||||
return issues_model.CreateReaction(ctx, setting.UI.ReactionsLookup, &issues_model.ReactionOptions{
|
||||
Type: content,
|
||||
DoerID: doer.ID,
|
||||
IssueID: comment.Issue.ID,
|
||||
|
||||
@@ -90,9 +90,7 @@ func TestIssueReactionCount(t *testing.T) {
|
||||
addReaction(t, user4, issue, nil, "heart")
|
||||
addReaction(t, ghost, issue, nil, "-1")
|
||||
|
||||
reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{IssueIDOnly: issue.ID})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reactionsList, 7)
|
||||
_, err = reactionsList.LoadUsers(t.Context(), repo)
|
||||
@@ -136,10 +134,7 @@ func TestIssueCommentDeleteReaction(t *testing.T) {
|
||||
addReaction(t, org3, nil, comment, "heart")
|
||||
addReaction(t, user4, nil, comment, "+1")
|
||||
|
||||
reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{
|
||||
IssueID: comment.IssueID,
|
||||
CommentID: comment.ID,
|
||||
})
|
||||
reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{CommentID: comment.ID})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reactionsList, 4)
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package release
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func AllowedReleaseReactions() []string {
|
||||
return setting.UI.ReleaseReactions
|
||||
}
|
||||
|
||||
func CreateReleaseReaction(ctx context.Context, doer *user_model.User, rel *repo_model.Release, content string) (*issues_model.Reaction, error) {
|
||||
if err := rel.LoadRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, rel.PublisherID, rel.Repo.OwnerID) {
|
||||
return nil, user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return issues_model.CreateReaction(ctx, setting.UI.ReleaseReactionsLookup, &issues_model.ReactionOptions{
|
||||
Type: content,
|
||||
DoerID: doer.ID,
|
||||
ReleaseID: rel.ID,
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
@@ -421,6 +422,10 @@ func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *re
|
||||
return fmt.Errorf("DeleteAttachments: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.DeleteByBean(ctx, &issues_model.Reaction{ReleaseID: rel.ID}); err != nil {
|
||||
return fmt.Errorf("DeleteReleaseByID: %w", err)
|
||||
}
|
||||
|
||||
for i := range rel.Attachments {
|
||||
attachment := rel.Attachments[i]
|
||||
if err := storage.Attachments.Delete(attachment.RelativePath()); err != nil {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
@@ -407,6 +409,25 @@ func TestCreateNewTag(t *testing.T) {
|
||||
"v2.0 is released \n\n BUGFIX: .... \n\n 123"))
|
||||
}
|
||||
|
||||
func TestCreateReleaseReaction(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1})
|
||||
|
||||
assert.Equal(t, []string{"+1", "laugh", "hooray", "heart", "rocket", "eyes"}, AllowedReleaseReactions())
|
||||
|
||||
_, err := CreateReleaseReaction(t.Context(), user, release, "heart")
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, DeleteReleaseByID(t.Context(), repo, release, user, false))
|
||||
unittest.AssertNotExistsBean(t, &issues_model.Reaction{ReleaseID: release.ID})
|
||||
|
||||
assert.NoError(t, db.Insert(t.Context(), &user_model.Blocking{BlockerID: repo.OwnerID, BlockeeID: user.ID}))
|
||||
_, err = CreateReleaseReaction(t.Context(), user, release, "heart")
|
||||
assert.ErrorIs(t, err, user_model.ErrBlockedUser)
|
||||
}
|
||||
|
||||
func TestRelease_DatedByTargetCommit(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
@@ -139,6 +139,11 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).In("release_id", builder.Select("id").From("`release`").Where(builder.Eq{"repo_id": repoID})).
|
||||
Delete(&issues_model.Reaction{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupEphemeralRunnersByPickedTaskOfRepo deletes ephemeral global/org/user that have started any task of this repo
|
||||
// The cannot pick a second task hardening for ephemeral runners expect that task objects remain available until runner deletion
|
||||
// This method will delete affected ephemeral global/org/user runners
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -75,6 +76,7 @@ func TestDeleteRepositoryDirectlyPurgesRepoScopedRows(t *testing.T) {
|
||||
&git_model.RenamedBranch{RepoID: 1, From: "old-name", To: "new-name"},
|
||||
&git_model.CommitStatusSummary{RepoID: 1, SHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", State: "success"},
|
||||
&repo_model.RepoTransfer{RepoID: 1, DoerID: 2, RecipientID: 3},
|
||||
&issues_model.Reaction{Type: "heart", UserID: 2, ReleaseID: 1},
|
||||
))
|
||||
unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusIndex{RepoID: 1})
|
||||
|
||||
@@ -87,4 +89,5 @@ func TestDeleteRepositoryDirectlyPurgesRepoScopedRows(t *testing.T) {
|
||||
unittest.AssertNotExistsBean(t, &git_model.CommitStatusSummary{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &git_model.CommitStatusIndex{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &repo_model.RepoTransfer{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &issues_model.Reaction{ReleaseID: 1})
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
|
||||
}
|
||||
|
||||
// Delete Reactions
|
||||
if err = issues_model.DeleteReaction(ctx, &issues_model.ReactionOptions{DoerID: u.ID}); err != nil {
|
||||
if err = issues_model.DeleteUserReactions(ctx, u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,6 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if not $repo.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID)}}
|
||||
{{end}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_comment" "ConvContentPage" "pr-files" "ConvContentItem" $comment}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,10 +73,11 @@
|
||||
{{template "repo/issue/view_content/attachments" dict "Attachments" $comment.Attachments "RenderedContent" $comment.RenderedContent}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{$reactions := $comment.Reactions.GroupByType}}
|
||||
{{if $reactions}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID) "Reactions" $reactions}}
|
||||
{{end}}
|
||||
{{template "shared/reactions" dict
|
||||
"AllowedReactions" $root.AllowedIssueReactions
|
||||
"ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID)
|
||||
"Reactions" $comment.Reactions.GroupByType
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -38,9 +38,6 @@
|
||||
</div>
|
||||
<div class="comment-header-right">
|
||||
{{template "repo/issue/view_content/show_role" dict "ShowRole" .Issue.ShowRole "IgnorePoster" true}}
|
||||
{{if not $.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}}
|
||||
{{end}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_main" "ConvContentItem" .Issue}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,10 +55,11 @@
|
||||
{{template "repo/issue/view_content/attachments" dict "Attachments" .Issue.Attachments "RenderedContent" .Issue.RenderedContent}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{$reactions := .Issue.Reactions.GroupByType}}
|
||||
{{if $reactions}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions}}
|
||||
{{end}}
|
||||
{{template "shared/reactions" dict
|
||||
"AllowedReactions" $.AllowedIssueReactions
|
||||
"ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)
|
||||
"Reactions" .Issue.Reactions.GroupByType
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{{if ctx.RootData.IsSigned}}
|
||||
<div class="item action ui dropdown jump pointing top right select-reaction" data-action-url="{{.ActionURL}}" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">
|
||||
<a class="muted">{{svg "octicon-smiley"}}</a>
|
||||
<div class="menu">
|
||||
{{range $value := AllowedReactions}}
|
||||
<a class="item emoji" data-tooltip-content="{{$value}}" aria-label="{{$value}}" data-reaction-content="{{$value}}" data-global-click="onCommentReactionButtonClick">{{ReactionToEmoji $value}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -53,9 +53,6 @@
|
||||
</div>
|
||||
<div class="comment-header-right">
|
||||
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
|
||||
{{if not $.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
|
||||
{{end}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_comment" "ConvContentItem" $comment}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,10 +70,11 @@
|
||||
{{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{$reactions := .Reactions.GroupByType}}
|
||||
{{if $reactions}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}}
|
||||
{{end}}
|
||||
{{template "shared/reactions" dict
|
||||
"AllowedReactions" $.AllowedIssueReactions
|
||||
"ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)
|
||||
"Reactions" .Reactions.GroupByType
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Type 1}}
|
||||
@@ -423,7 +421,6 @@
|
||||
<div class="comment-header-right">
|
||||
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
|
||||
{{if not $.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ConvContentType" "pull_review" "ConvContentItem" $comment}}
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -442,10 +439,11 @@
|
||||
{{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{$reactions := .Reactions.GroupByType}}
|
||||
{{if $reactions}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}}
|
||||
{{end}}
|
||||
{{template "shared/reactions" dict
|
||||
"AllowedReactions" $.AllowedIssueReactions
|
||||
"ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)
|
||||
"Reactions" .Reactions.GroupByType
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -92,7 +92,6 @@ The variables in "ctx.Data" are different in each case, making this template fra
|
||||
<div class="comment-header-right">
|
||||
{{template "repo/issue/view_content/show_role" dict "ShowRole" $comment.ShowRole}}
|
||||
{{if not $repo.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID)}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_comment" "ConvContentItem" $comment}}
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -112,10 +111,11 @@ The variables in "ctx.Data" are different in each case, making this template fra
|
||||
{{template "repo/issue/view_content/attachments" dict "Attachments" $comment.Attachments "RenderedContent" $comment.RenderedContent}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{$reactions := $comment.Reactions.GroupByType}}
|
||||
{{if $reactions}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID) "Reactions" $reactions}}
|
||||
{{end}}
|
||||
{{template "shared/reactions" dict
|
||||
"AllowedReactions" $root.AllowedIssueReactions
|
||||
"ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID)
|
||||
"Reactions" $comment.Reactions.GroupByType
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<div class="bottom-reactions" data-action-url="{{$.ActionURL}}" role="group" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">
|
||||
{{range $key, $value := .Reactions}}
|
||||
{{$hasReacted := $value.HasUser ctx.RootData.SignedUserID}}
|
||||
<a role="button" class="ui label basic{{if $hasReacted}} primary{{end}}{{if not ctx.RootData.IsSigned}} disabled{{end}}"
|
||||
data-global-click="onCommentReactionButtonClick"
|
||||
data-tooltip-content title="{{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
|
||||
aria-label="{{$key}}: {{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
|
||||
data-tooltip-placement="bottom-start"
|
||||
data-reaction-content="{{$key}}" data-has-reacted="{{$hasReacted}}">
|
||||
<span class="reaction">{{ReactionToEmoji $key}}</span>
|
||||
<span class="reaction-count">{{len $value}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if AllowedReactions}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" .ActionURL}}
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -107,6 +107,13 @@
|
||||
{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{if not (or $release.IsTag $release.IsDraft)}}
|
||||
{{template "shared/reactions" dict
|
||||
"ActionURL" (printf "%s/releases/%d/reactions" $.RepoLink $release.ID)
|
||||
"Reactions" $info.Reactions.GroupByType
|
||||
"AllowedReactions" $.AllowedReleaseReactions
|
||||
}}
|
||||
{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{{$allowedReactions := $.AllowedReactions}}
|
||||
{{$canReact := and $allowedReactions ctx.RootData.IsSigned (not ctx.RootData.Repository.IsArchived)}}
|
||||
{{if or .Reactions $canReact}}
|
||||
<div class="bottom-reactions" data-action-url="{{$.ActionURL}}" role="group" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">
|
||||
{{if $canReact}}
|
||||
<button type="button" class="select-reaction" data-global-click="onReactionPickerClick" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">{{svg "octicon-smiley"}}</button>
|
||||
<div class="tippy-target">
|
||||
<div class="reaction-picker">
|
||||
{{range $value := $allowedReactions}}
|
||||
<a class="item emoji" role="menuitem" data-tooltip-content="{{$value}}" aria-label="{{$value}}" data-reaction-content="{{$value}}" data-has-reacted="{{(index $.Reactions $value).HasUser ctx.RootData.SignedUserID}}" data-global-click="onCommentReactionButtonClick">{{ReactionToEmoji $value}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range $key, $value := .Reactions}}
|
||||
{{$hasReacted := $value.HasUser ctx.RootData.SignedUserID}}
|
||||
<a role="button" class="ui label basic{{if not $canReact}} disabled{{end}}"
|
||||
data-global-click="onCommentReactionButtonClick"
|
||||
data-tooltip-content="{{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
|
||||
aria-label="{{$key}}: {{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
|
||||
data-tooltip-placement="bottom-start"
|
||||
data-reaction-content="{{$key}}" data-has-reacted="{{$hasReacted}}">
|
||||
<span class="reaction">{{ReactionToEmoji $key}}</span>
|
||||
<span>{{len $value}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -12,15 +12,20 @@ test('toggle issue reactions', async ({page, request}) => {
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
|
||||
const issueComment = page.locator('.timeline-item.comment.issue-content-comment');
|
||||
const reactions = page.locator('.timeline-item.comment.issue-content-comment').getByRole('group', {name: 'Reactions'});
|
||||
const reactionPicker = reactions.getByRole('button', {name: 'Reactions', exact: true});
|
||||
|
||||
const reactionPicker = issueComment.locator('.select-reaction');
|
||||
await reactionPicker.click();
|
||||
await reactionPicker.getByLabel('+1').click();
|
||||
|
||||
const reactions = issueComment.getByRole('group', {name: 'Reactions'});
|
||||
await page.getByRole('menuitem', {name: '+1', exact: true}).click();
|
||||
await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1');
|
||||
|
||||
await reactions.getByRole('button', {name: /^\+1:/}).click();
|
||||
await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0);
|
||||
|
||||
await reactionPicker.press('Enter');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(reactions.getByRole('button', {name: /^-1:/})).toContainText('1');
|
||||
await expect(reactionPicker).toBeFocused();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
@@ -280,3 +281,29 @@ func TestEditReleaseAttachmentRejectsForbiddenRename(t *testing.T) {
|
||||
attachment = unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: attachment.ID})
|
||||
assert.NotEqual(t, "evil.exe", attachment.Name)
|
||||
}
|
||||
|
||||
func TestReleaseReaction(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
session := loginUser(t, "user2")
|
||||
reactReq := func(releaseID int64, action, content string) *RequestWrapper {
|
||||
return NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/releases/%d/reactions/%s", releaseID, action), map[string]string{"content": content})
|
||||
}
|
||||
|
||||
session.MakeRequest(t, reactReq(1, "react", "-1"), http.StatusForbidden)
|
||||
|
||||
session.MakeRequest(t, reactReq(1, "react", "heart"), http.StatusOK)
|
||||
session.MakeRequest(t, reactReq(1, "react", "heart"), http.StatusOK)
|
||||
unittest.AssertCount(t, &issues_model.Reaction{ReleaseID: 1}, 1)
|
||||
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/releases"), http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
AssertHTMLElement(t, htmlDoc, `.bottom-reactions a.ui.label[data-reaction-content="heart"]`, 1)
|
||||
AssertHTMLElement(t, htmlDoc, `.reaction-picker [data-reaction-content="-1"]`, 0)
|
||||
|
||||
session.MakeRequest(t, reactReq(1, "unreact", "heart"), http.StatusOK)
|
||||
unittest.AssertCount(t, &issues_model.Reaction{ReleaseID: 1}, 0)
|
||||
|
||||
for _, releaseID := range []int64{2, 3, 4} {
|
||||
session.MakeRequest(t, reactReq(releaseID, "react", "heart"), http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,61 @@
|
||||
.bottom-reactions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: 0 1em 1em;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 0 14px 14px;
|
||||
}
|
||||
|
||||
.timeline-item .conversation-holder .bottom-reactions {
|
||||
margin: 1em 0 0 24px;
|
||||
margin: 14px 0 0 24px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.releases .bottom-reactions {
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.bottom-reactions .select-reaction,
|
||||
.bottom-reactions .ui.label {
|
||||
padding: 2px 6px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.bottom-reactions .ui.label.primary {
|
||||
background-color: var(--color-reaction-active-bg) !important;
|
||||
.bottom-reactions .select-reaction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius-full);
|
||||
border: 1px solid var(--color-light-border);
|
||||
background: var(--color-reaction-bg);
|
||||
color: var(--color-text-light-1);
|
||||
}
|
||||
|
||||
.bottom-reactions .ui.label:hover {
|
||||
background-color: var(--color-reaction-hover-bg) !important;
|
||||
.bottom-reactions .select-reaction:hover,
|
||||
.bottom-reactions .select-reaction[aria-expanded="true"] {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bottom-reactions .ui.label {
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bottom-reactions .ui.basic.label[data-has-reacted="true"] {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-reaction-active-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bottom-reactions .ui.label:not(.disabled):hover {
|
||||
background: var(--color-reaction-hover-bg);
|
||||
}
|
||||
|
||||
.bottom-reactions .ui.label.disabled {
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bottom-reactions .ui.label .reaction {
|
||||
@@ -37,35 +68,22 @@
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.bottom-reactions .reaction-count {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.ui.dropdown.select-reaction .menu.visible {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.ui.dropdown.select-reaction .menu > .item {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
border-radius: var(--border-radius);
|
||||
.reaction-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.tippy-box .reaction-picker .item {
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 17.5px;
|
||||
}
|
||||
|
||||
.bottom-reactions .select-reaction {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.bottom-reactions .select-reaction:not(.active) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.bottom-reactions:hover .select-reaction {
|
||||
visibility: visible;
|
||||
.tippy-box .reaction-picker .item[data-has-reacted="true"] {
|
||||
background: var(--color-reaction-active-bg);
|
||||
}
|
||||
@@ -1,31 +1,44 @@
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {performFetchActionRequest} from '../../modules/fetch-action.ts';
|
||||
import {registerGlobalEventFunc} from '../../modules/observer.ts';
|
||||
import {createTippy} from '../../modules/tippy.ts';
|
||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||
|
||||
export function initCompReactionSelector() {
|
||||
registerGlobalEventFunc('click', 'onReactionPickerClick', (btn: HTMLElement) => {
|
||||
if (btn._tippy) return;
|
||||
createTippy(btn, {
|
||||
content: btn.nextElementSibling!,
|
||||
theme: 'menu',
|
||||
arrow: false,
|
||||
maxWidth: 'none',
|
||||
placement: 'bottom-start',
|
||||
trigger: 'click',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
}).show();
|
||||
});
|
||||
|
||||
registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: Event) => {
|
||||
// there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
|
||||
e.preventDefault();
|
||||
|
||||
if (target.classList.contains('disabled')) return;
|
||||
|
||||
const actionUrl = target.closest('[data-action-url]')!.getAttribute('data-action-url');
|
||||
const reactionContent = target.getAttribute('data-reaction-content')!;
|
||||
|
||||
const commentContainer = target.closest('.comment-container')!;
|
||||
|
||||
const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
|
||||
const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
|
||||
const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
|
||||
|
||||
const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
|
||||
data: new URLSearchParams({content: reactionContent}),
|
||||
const picker = target.closest('[data-tippy-root]')?._tippy;
|
||||
picker?.hide();
|
||||
const bottomReactions = (picker?.reference ?? target).closest<HTMLElement>('.bottom-reactions')!;
|
||||
const hasReacted = target.getAttribute('data-has-reacted') === 'true';
|
||||
const res = await performFetchActionRequest(bottomReactions, {
|
||||
method: 'POST',
|
||||
url: `${bottomReactions.getAttribute('data-action-url')}/${hasReacted ? 'unreact' : 'react'}`,
|
||||
data: new URLSearchParams({content: target.getAttribute('data-reaction-content')!}),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res) return;
|
||||
|
||||
const data = await res.json();
|
||||
bottomReactions?.remove();
|
||||
if (data.html) {
|
||||
commentContainer.insertAdjacentHTML('beforeend', data.html);
|
||||
}
|
||||
const hadFocus = bottomReactions.contains(document.activeElement);
|
||||
bottomReactions.querySelector('.select-reaction')?._tippy?.destroy();
|
||||
const newBottomReactions = createElementFromHTML(data.html);
|
||||
bottomReactions.replaceWith(newBottomReactions);
|
||||
if (hadFocus) newBottomReactions.querySelector<HTMLElement>('.select-reaction')?.focus();
|
||||
});
|
||||
}
|
||||
@@ -123,7 +123,9 @@ async function handleFetchActionError(el: HTMLElement, resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (respJson?.errorMessage) {
|
||||
if (respJson?.redirect) {
|
||||
fetchActionDoRedirect(respJson.redirect);
|
||||
} else if (respJson?.errorMessage) {
|
||||
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
|
||||
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
|
||||
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
|
||||
|
||||
@@ -81,8 +81,8 @@ document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (!inMenu && focused !== menuInstance.reference && focused !== document.body) return; // body: macOS Safari and Firefox do not focus clicked buttons
|
||||
if (e.key === 'Escape') {
|
||||
menuInstance.hide();
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
focusMenuItem(menuInstance, e.key === 'ArrowDown' ? 1 : -1);
|
||||
} else if (['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft'].includes(e.key)) {
|
||||
focusMenuItem(menuInstance, e.key === 'ArrowDown' || e.key === 'ArrowRight' ? 1 : -1);
|
||||
} else if ((e.key === 'Enter' || e.key === ' ') && inMenu) {
|
||||
focused.click();
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user