diff --git a/models/activities/action.go b/models/activities/action.go
index 57f579372f..2e8a9c1de2 100644
--- a/models/activities/action.go
+++ b/models/activities/action.go
@@ -580,7 +580,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
 
 		if repoChanged {
 			// Add feeds for user self and all watchers.
-			watchers, err = repo_model.GetWatchers(ctx, act.RepoID)
+			watchers, err = repo_model.GetWatchersExcludeBlocked(ctx, act.RepoID, act.ActUserID)
 			if err != nil {
 				return fmt.Errorf("get watchers: %w", err)
 			}
diff --git a/models/activities/notification.go b/models/activities/notification.go
index 75276a0443..b888294557 100644
--- a/models/activities/notification.go
+++ b/models/activities/notification.go
@@ -235,6 +235,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
 		for _, id := range issueUnWatches {
 			toNotify.Remove(id)
 		}
+
+		// Remove users who have the notification author blocked.
+		blockedAuthorIDs, err := user_model.ListBlockedByUsersID(ctx, notificationAuthorID)
+		if err != nil {
+			return err
+		}
+		for _, id := range blockedAuthorIDs {
+			toNotify.Remove(id)
+		}
 	}
 
 	err = issue.LoadRepo(ctx)
diff --git a/models/fixtures/forgejo_blocked_user.yml b/models/fixtures/forgejo_blocked_user.yml
new file mode 100644
index 0000000000..88c378a846
--- /dev/null
+++ b/models/fixtures/forgejo_blocked_user.yml
@@ -0,0 +1,5 @@
+-
+  id: 1
+  user_id: 4
+  block_id: 1
+  created_unix: 1671607299
diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go
index 88bbef70c7..713db4a090 100644
--- a/models/forgejo_migrations/migrate.go
+++ b/models/forgejo_migrations/migrate.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"os"
 
+	forgejo_v1_20 "code.gitea.io/gitea/models/forgejo_migrations/v1_20"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -34,7 +35,9 @@ func NewMigration(desc string, fn func(*xorm.Engine) error) *Migration {
 
 // This is a sequence of additional Forgejo migrations.
 // Add new migrations to the bottom of the list.
-var migrations = []*Migration{}
+var migrations = []*Migration{
+	NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser),
+}
 
 // GetCurrentDBVersion returns the current Forgejo database version.
 func GetCurrentDBVersion(x *xorm.Engine) (int64, error) {
diff --git a/models/forgejo_migrations/v1_20/v1.go b/models/forgejo_migrations/v1_20/v1.go
new file mode 100644
index 0000000000..1097613655
--- /dev/null
+++ b/models/forgejo_migrations/v1_20/v1.go
@@ -0,0 +1,21 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgejo_v1_20 //nolint:revive
+
+import (
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+func AddForgejoBlockedUser(x *xorm.Engine) error {
+	type ForgejoBlockedUser struct {
+		ID          int64              `xorm:"pk autoincr"`
+		BlockID     int64              `xorm:"index"`
+		UserID      int64              `xorm:"index"`
+		CreatedUnix timeutil.TimeStamp `xorm:"created"`
+	}
+
+	return x.Sync(new(ForgejoBlockedUser))
+}
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
index 80699a57b4..7b46e55d9b 100644
--- a/models/issues/issue_test.go
+++ b/models/issues/issue_test.go
@@ -451,6 +451,8 @@ func TestIssue_ResolveMentions(t *testing.T) {
 	testSuccess("user2", "repo1", "user1", []string{"nonexisting"}, []int64{})
 	// Public repo, doer
 	testSuccess("user2", "repo1", "user1", []string{"user1"}, []int64{})
+	// Public repo, blocked user
+	testSuccess("user2", "repo1", "user1", []string{"user4"}, []int64{})
 	// Private repo, team member
 	testSuccess("user17", "big_test_private_4", "user20", []string{"user2"}, []int64{2})
 	// Private repo, not a team member
diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go
index b6fd720fe5..1c3846478a 100644
--- a/models/issues/issue_update.go
+++ b/models/issues/issue_update.go
@@ -608,9 +608,11 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
 				teamusers := make([]*user_model.User, 0, 20)
 				if err := db.GetEngine(ctx).
 					Join("INNER", "team_user", "team_user.uid = `user`.id").
+					Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
 					In("`team_user`.team_id", checked).
 					And("`user`.is_active = ?", true).
 					And("`user`.prohibit_login = ?", false).
+					And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
 					Find(&teamusers); err != nil {
 					return nil, fmt.Errorf("get teams users: %w", err)
 				}
@@ -644,8 +646,10 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
 
 	unchecked := make([]*user_model.User, 0, len(mentionUsers))
 	if err := db.GetEngine(ctx).
+		Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
 		Where("`user`.is_active = ?", true).
 		And("`user`.prohibit_login = ?", false).
+		And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
 		In("`user`.lower_name", mentionUsers).
 		Find(&unchecked); err != nil {
 		return nil, fmt.Errorf("find mentioned users: %w", err)
diff --git a/models/issues/reaction.go b/models/issues/reaction.go
index 293dfa3fd1..a005b45364 100644
--- a/models/issues/reaction.go
+++ b/models/issues/reaction.go
@@ -218,12 +218,12 @@ type ReactionOptions struct {
 }
 
 // CreateReaction creates reaction for issue or comment.
-func CreateReaction(opts *ReactionOptions) (*Reaction, error) {
+func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
 	if !setting.UI.ReactionsLookup.Contains(opts.Type) {
 		return nil, ErrForbiddenIssueReaction{opts.Type}
 	}
 
-	ctx, committer, err := db.TxContext(db.DefaultContext)
+	ctx, committer, err := db.TxContext(ctx)
 	if err != nil {
 		return nil, err
 	}
@@ -240,25 +240,6 @@ func CreateReaction(opts *ReactionOptions) (*Reaction, error) {
 	return reaction, nil
 }
 
-// CreateIssueReaction creates a reaction on issue.
-func CreateIssueReaction(doerID, issueID int64, content string) (*Reaction, error) {
-	return CreateReaction(&ReactionOptions{
-		Type:    content,
-		DoerID:  doerID,
-		IssueID: issueID,
-	})
-}
-
-// CreateCommentReaction creates a reaction on comment.
-func CreateCommentReaction(doerID, issueID, commentID int64, content string) (*Reaction, error) {
-	return CreateReaction(&ReactionOptions{
-		Type:      content,
-		DoerID:    doerID,
-		IssueID:   issueID,
-		CommentID: commentID,
-	})
-}
-
 // DeleteReaction deletes reaction for issue or comment.
 func DeleteReaction(ctx context.Context, opts *ReactionOptions) error {
 	reaction := &Reaction{
diff --git a/models/issues/reaction_test.go b/models/issues/reaction_test.go
index ddd0e2d04c..80b4e64b95 100644
--- a/models/issues/reaction_test.go
+++ b/models/issues/reaction_test.go
@@ -19,11 +19,14 @@ import (
 func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) {
 	var reaction *issues_model.Reaction
 	var err error
-	if commentID == 0 {
-		reaction, err = issues_model.CreateIssueReaction(doerID, issueID, content)
-	} else {
-		reaction, err = issues_model.CreateCommentReaction(doerID, issueID, commentID, content)
-	}
+	// NOTE: This doesn't do user blocking checking.
+	reaction, err = issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{
+		DoerID:    doerID,
+		IssueID:   issueID,
+		CommentID: commentID,
+		Type:      content,
+	})
+
 	assert.NoError(t, err)
 	assert.NotNil(t, reaction)
 }
@@ -49,7 +52,7 @@ func TestIssueAddDuplicateReaction(t *testing.T) {
 
 	addReaction(t, user1.ID, issue1ID, 0, "heart")
 
-	reaction, err := issues_model.CreateReaction(&issues_model.ReactionOptions{
+	reaction, err := issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{
 		DoerID:  user1.ID,
 		IssueID: issue1ID,
 		Type:    "heart",
diff --git a/models/repo/watch.go b/models/repo/watch.go
index 00f313ca7c..6ff3a3f7b3 100644
--- a/models/repo/watch.go
+++ b/models/repo/watch.go
@@ -10,6 +10,8 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/builder"
 )
 
 // WatchMode specifies what kind of watch the user has on a repository
@@ -142,6 +144,21 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
 		Find(&watches)
 }
 
+// GetWatchersExcludeBlocked returns all watchers of given repository, whereby
+// the doer isn't blocked by one of the watchers.
+func GetWatchersExcludeBlocked(ctx context.Context, repoID, doerID int64) ([]*Watch, error) {
+	watches := make([]*Watch, 0, 10)
+	return watches, db.GetEngine(ctx).
+		Join("INNER", "`user`", "`user`.id = `watch`.user_id").
+		Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `watch`.user_id").
+		Where("`watch`.repo_id=?", repoID).
+		And("`watch`.mode<>?", WatchModeDont).
+		And("`user`.is_active=?", true).
+		And("`user`.prohibit_login=?", false).
+		And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doerID})).
+		Find(&watches)
+}
+
 // GetRepoWatchersIDs returns IDs of watchers for a given repo ID
 // but avoids joining with `user` for performance reasons
 // User permissions must be verified elsewhere if required
diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go
index 8b8c6d6250..b6ae2a0ef5 100644
--- a/models/repo/watch_test.go
+++ b/models/repo/watch_test.go
@@ -43,6 +43,24 @@ func TestGetWatchers(t *testing.T) {
 	assert.Len(t, watches, 0)
 }
 
+func TestGetWatchersExcludeBlocked(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	watches, err := repo_model.GetWatchersExcludeBlocked(db.DefaultContext, repo.ID, 1)
+	assert.NoError(t, err)
+
+	// One watchers are inactive and one watcher is blocked, thus minus 2
+	assert.Len(t, watches, repo.NumWatches-2)
+	for _, watch := range watches {
+		assert.EqualValues(t, repo.ID, watch.RepoID)
+	}
+
+	watches, err = repo_model.GetWatchersExcludeBlocked(db.DefaultContext, unittest.NonexistentID, 1)
+	assert.NoError(t, err)
+	assert.Len(t, watches, 0)
+}
+
 func TestRepository_GetWatchers(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
diff --git a/models/user/block.go b/models/user/block.go
new file mode 100644
index 0000000000..64dd93ed38
--- /dev/null
+++ b/models/user/block.go
@@ -0,0 +1,78 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+	"context"
+	"errors"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/timeutil"
+)
+
+// ErrBlockedByUser defines an error stating that the user is not allowed to perform the action because they are blocked.
+var ErrBlockedByUser = errors.New("user is blocked by the poster or repository owner")
+
+// BlockedUser represents a blocked user entry.
+type BlockedUser struct {
+	ID int64 `xorm:"pk autoincr"`
+	// UID of the one who got blocked.
+	BlockID int64 `xorm:"index"`
+	// UID of the one who did the block action.
+	UserID int64 `xorm:"index"`
+
+	CreatedUnix timeutil.TimeStamp `xorm:"created"`
+}
+
+// TableName provides the real table name
+func (*BlockedUser) TableName() string {
+	return "forgejo_blocked_user"
+}
+
+func init() {
+	db.RegisterModel(new(BlockedUser))
+}
+
+// IsBlocked returns if userID has blocked blockID.
+func IsBlocked(ctx context.Context, userID, blockID int64) bool {
+	has, _ := db.GetEngine(ctx).Exist(&BlockedUser{UserID: userID, BlockID: blockID})
+	return has
+}
+
+// IsBlockedMultiple returns if one of the userIDs has blocked blockID.
+func IsBlockedMultiple(ctx context.Context, userIDs []int64, blockID int64) bool {
+	has, _ := db.GetEngine(ctx).In("user_id", userIDs).Exist(&BlockedUser{BlockID: blockID})
+	return has
+}
+
+// UnblockUser removes the blocked user entry.
+func UnblockUser(ctx context.Context, userID, blockID int64) error {
+	_, err := db.GetEngine(ctx).Delete(&BlockedUser{UserID: userID, BlockID: blockID})
+	return err
+}
+
+// ListBlockedUsers returns the users that the user has blocked.
+func ListBlockedUsers(ctx context.Context, userID int64) ([]*User, error) {
+	users := make([]*User, 0, 8)
+	err := db.GetEngine(ctx).
+		Select("`user`.*").
+		Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id").
+		Where("`forgejo_blocked_user`.user_id=?", userID).
+		Find(&users)
+
+	return users, err
+}
+
+// ListBlockedByUsersID returns the ids of the users that blocked the user.
+func ListBlockedByUsersID(ctx context.Context, userID int64) ([]int64, error) {
+	users := make([]int64, 0, 8)
+	err := db.GetEngine(ctx).
+		Table("user").
+		Select("`user`.id").
+		Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.user_id").
+		Where("`forgejo_blocked_user`.block_id=?", userID).
+		Find(&users)
+
+	return users, err
+}
diff --git a/models/user/block_test.go b/models/user/block_test.go
new file mode 100644
index 0000000000..d800eeaade
--- /dev/null
+++ b/models/user/block_test.go
@@ -0,0 +1,63 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user_test
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIsBlocked(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
+
+	// Simple test cases to ensure the function can also respond with false.
+	assert.False(t, user_model.IsBlocked(db.DefaultContext, 1, 1))
+	assert.False(t, user_model.IsBlocked(db.DefaultContext, 3, 2))
+}
+
+func TestIsBlockedMultiple(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4}, 1))
+	assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4, 3, 4, 5}, 1))
+
+	// Simple test cases to ensure the function can also respond with false.
+	assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{1}, 1))
+	assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{3, 4, 1}, 2))
+}
+
+func TestUnblockUser(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
+
+	assert.NoError(t, user_model.UnblockUser(db.DefaultContext, 4, 1))
+
+	// Simple test cases to ensure the function can also respond with false.
+	assert.False(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
+}
+
+func TestListBlockedUsers(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	blockedUsers, err := user_model.ListBlockedUsers(db.DefaultContext, 4)
+	assert.NoError(t, err)
+	if assert.Len(t, blockedUsers, 1) {
+		assert.EqualValues(t, 1, blockedUsers[0].ID)
+	}
+}
+
+func TestListBlockedByUsersID(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	blockedByUserIDs, err := user_model.ListBlockedByUsersID(db.DefaultContext, 1)
+	assert.NoError(t, err)
+	if assert.Len(t, blockedByUserIDs, 1) {
+		assert.EqualValues(t, 4, blockedByUserIDs[0])
+	}
+}
diff --git a/models/user/follow.go b/models/user/follow.go
index 7efecc26a7..936efbc164 100644
--- a/models/user/follow.go
+++ b/models/user/follow.go
@@ -4,6 +4,8 @@
 package user
 
 import (
+	"context"
+
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/modules/timeutil"
 )
@@ -27,12 +29,12 @@ func IsFollowing(userID, followID int64) bool {
 }
 
 // FollowUser marks someone be another's follower.
-func FollowUser(userID, followID int64) (err error) {
+func FollowUser(ctx context.Context, userID, followID int64) (err error) {
 	if userID == followID || IsFollowing(userID, followID) {
 		return nil
 	}
 
-	ctx, committer, err := db.TxContext(db.DefaultContext)
+	ctx, committer, err := db.TxContext(ctx)
 	if err != nil {
 		return err
 	}
@@ -53,12 +55,12 @@ func FollowUser(userID, followID int64) (err error) {
 }
 
 // UnfollowUser unmarks someone as another's follower.
-func UnfollowUser(userID, followID int64) (err error) {
+func UnfollowUser(ctx context.Context, userID, followID int64) (err error) {
 	if userID == followID || !IsFollowing(userID, followID) {
 		return nil
 	}
 
-	ctx, committer, err := db.TxContext(db.DefaultContext)
+	ctx, committer, err := db.TxContext(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/models/user/user_test.go b/models/user/user_test.go
index 44eaf63556..d5f0e80510 100644
--- a/models/user/user_test.go
+++ b/models/user/user_test.go
@@ -449,13 +449,13 @@ func TestFollowUser(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
 	testSuccess := func(followerID, followedID int64) {
-		assert.NoError(t, user_model.FollowUser(followerID, followedID))
+		assert.NoError(t, user_model.FollowUser(db.DefaultContext, followerID, followedID))
 		unittest.AssertExistsAndLoadBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID})
 	}
 	testSuccess(4, 2)
 	testSuccess(5, 2)
 
-	assert.NoError(t, user_model.FollowUser(2, 2))
+	assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
 
 	unittest.CheckConsistencyFor(t, &user_model.User{})
 }
@@ -464,7 +464,7 @@ func TestUnfollowUser(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
 	testSuccess := func(followerID, followedID int64) {
-		assert.NoError(t, user_model.UnfollowUser(followerID, followedID))
+		assert.NoError(t, user_model.UnfollowUser(db.DefaultContext, followerID, followedID))
 		unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID})
 	}
 	testSuccess(4, 2)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index f0f2161559..053ab1ff42 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -588,11 +588,17 @@ overview = Overview
 following = Following
 follow = Follow
 unfollow = Unfollow
+block = Block
+unblock = Unblock
 heatmap.loading = Loading Heatmap…
 user_bio = Biography
 disabled_public_activity = This user has disabled the public visibility of the activity.
 email_visibility.limited = Your email address is visible to all authenticated users
 email_visibility.private = Your email address is only visible to you and administrators
+block_user = Block User
+block_user.detail = Please understand that if you block this user, other actions will be taken. Such as:
+block_user.detail_1 = You are being unfollowed from this user.
+block_user.detail_2 = This user cannot interact with your repositories, created issues and comments.
 
 form.name_reserved = The username "%s" is reserved.
 form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username.
@@ -616,6 +622,7 @@ account_link = Linked Accounts
 organization = Organizations
 uid = Uid
 webauthn = Security Keys
+blocked_users = Blocked Users
 
 public_profile = Public Profile
 biography_placeholder = Tell us a little bit about yourself
@@ -1626,6 +1633,7 @@ issues.content_history.delete_from_history = Delete from history
 issues.content_history.delete_from_history_confirm = Delete from history?
 issues.content_history.options = Options
 issues.reference_link = Reference: %s
+issues.blocked_by_user = You cannot create a issue on this repository because you are blocked by the repository owner.
 
 compare.compare_base = base
 compare.compare_head = compare
@@ -1698,6 +1706,7 @@ pulls.reject_count_n = "%d change requests"
 pulls.waiting_count_1 = "%d waiting review"
 pulls.waiting_count_n = "%d waiting reviews"
 pulls.wrong_commit_id = "commit id must be a commit id on the target branch"
+pulls.blocked_by_user = You cannot create a pull request on this repository because you are blocked by the repository owner.
 
 pulls.no_merge_desc = This pull request cannot be merged because all repository merge options are disabled.
 pulls.no_merge_helper = Enable merge options in the repository settings or merge the pull request manually.
diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go
index 49252f7a4b..3438443a2f 100644
--- a/routers/api/v1/repo/issue.go
+++ b/routers/api/v1/repo/issue.go
@@ -5,6 +5,7 @@
 package repo
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -652,7 +653,10 @@ func CreateIssue(ctx *context.APIContext) {
 	}
 
 	if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "BlockedByUser", err)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
 			return
 		}
diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go
index 5616e255ad..7696e121fc 100644
--- a/routers/api/v1/repo/issue_comment.go
+++ b/routers/api/v1/repo/issue_comment.go
@@ -364,7 +364,11 @@ func CreateIssueComment(ctx *context.APIContext) {
 
 	comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil)
 	if err != nil {
-		ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "CreateIssueComment", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
+		}
 		return
 	}
 
diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go
index 921f6e53f9..12be5c388f 100644
--- a/routers/api/v1/repo/issue_reaction.go
+++ b/routers/api/v1/repo/issue_reaction.go
@@ -8,11 +8,13 @@ import (
 	"net/http"
 
 	issues_model "code.gitea.io/gitea/models/issues"
+	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/context"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	"code.gitea.io/gitea/services/convert"
+	issue_service "code.gitea.io/gitea/services/issue"
 )
 
 // GetIssueCommentReactions list reactions of a comment from an issue
@@ -196,9 +198,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp
 
 	if isCreateType {
 		// PostIssueCommentReaction part
-		reaction, err := issues_model.CreateCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction)
+		reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Reaction)
 		if err != nil {
-			if issues_model.IsErrForbiddenIssueReaction(err) {
+			if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) {
 				ctx.Error(http.StatusForbidden, err.Error(), err)
 			} else if issues_model.IsErrReactionAlreadyExist(err) {
 				ctx.JSON(http.StatusOK, api.Reaction{
@@ -406,9 +408,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i
 
 	if isCreateType {
 		// PostIssueReaction part
-		reaction, err := issues_model.CreateIssueReaction(ctx.Doer.ID, issue.ID, form.Reaction)
+		reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction)
 		if err != nil {
-			if issues_model.IsErrForbiddenIssueReaction(err) {
+			if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) {
 				ctx.Error(http.StatusForbidden, err.Error(), err)
 			} else if issues_model.IsErrReactionAlreadyExist(err) {
 				ctx.JSON(http.StatusOK, api.Reaction{
diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go
index a507c1f44d..91e6527d85 100644
--- a/routers/api/v1/repo/pull.go
+++ b/routers/api/v1/repo/pull.go
@@ -418,7 +418,10 @@ func CreatePullRequest(ctx *context.APIContext) {
 	}
 
 	if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "BlockedByUser", err)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
 			return
 		}
diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go
index bc03b22ea7..364507711d 100644
--- a/routers/api/v1/user/follower.go
+++ b/routers/api/v1/user/follower.go
@@ -218,7 +218,7 @@ func Follow(ctx *context.APIContext) {
 	//   "204":
 	//     "$ref": "#/responses/empty"
 
-	if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
+	if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
 		ctx.Error(http.StatusInternalServerError, "FollowUser", err)
 		return
 	}
@@ -240,7 +240,7 @@ func Unfollow(ctx *context.APIContext) {
 	//   "204":
 	//     "$ref": "#/responses/empty"
 
-	if err := user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
+	if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
 		ctx.Error(http.StatusInternalServerError, "UnfollowUser", err)
 		return
 	}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 0756fcd533..f48df0494b 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1162,7 +1162,10 @@ func NewIssuePost(ctx *context.Context) {
 	}
 
 	if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.RenderWithErr(ctx.Tr("repo.issues.blocked_by_user"), tplIssueNew, form)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
 			return
 		}
@@ -3061,7 +3064,7 @@ func ChangeIssueReaction(ctx *context.Context) {
 
 	switch ctx.Params(":action") {
 	case "react":
-		reaction, err := issues_model.CreateIssueReaction(ctx.Doer.ID, issue.ID, form.Content)
+		reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content)
 		if err != nil {
 			if issues_model.IsErrForbiddenIssueReaction(err) {
 				ctx.ServerError("ChangeIssueReaction", err)
@@ -3163,7 +3166,7 @@ func ChangeCommentReaction(ctx *context.Context) {
 
 	switch ctx.Params(":action") {
 	case "react":
-		reaction, err := issues_model.CreateCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content)
+		reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Content)
 		if err != nil {
 			if issues_model.IsErrForbiddenIssueReaction(err) {
 				ctx.ServerError("ChangeIssueReaction", err)
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index 0127edb5a6..f6897b107b 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -1271,7 +1271,11 @@ func CompareAndPullRequestPost(ctx *context.Context) {
 	// instead of 500.
 
 	if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Flash.Error(ctx.Tr("repo.pulls.blocked_by_user"))
+			ctx.Redirect(ctx.Link)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
 			return
 		} else if git.IsErrPushRejected(err) {
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 6f9f84d60d..d91bf23f91 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -23,6 +23,7 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/routers/web/feed"
 	"code.gitea.io/gitea/routers/web/org"
+	user_service "code.gitea.io/gitea/services/user"
 )
 
 // Profile render user's profile page
@@ -58,8 +59,10 @@ func Profile(ctx *context.Context) {
 	}
 
 	var isFollowing bool
+	var isBlocked bool
 	if ctx.Doer != nil {
 		isFollowing = user_model.IsFollowing(ctx.Doer.ID, ctx.ContextUser.ID)
+		isBlocked = user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	}
 
 	ctx.Data["Title"] = ctx.ContextUser.DisplayName()
@@ -67,6 +70,7 @@ func Profile(ctx *context.Context) {
 	ctx.Data["ContextUser"] = ctx.ContextUser
 	ctx.Data["OpenIDs"] = openIDs
 	ctx.Data["IsFollowing"] = isFollowing
+	ctx.Data["IsBlocked"] = isBlocked
 
 	if setting.Service.EnableUserHeatmap {
 		data, err := activities_model.GetUserHeatmapDataByUser(ctx.ContextUser, ctx.Doer)
@@ -351,17 +355,31 @@ func Profile(ctx *context.Context) {
 // Action response for follow/unfollow user request
 func Action(ctx *context.Context) {
 	var err error
+	var redirectViaJSON bool
 	switch ctx.FormString("action") {
 	case "follow":
-		err = user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
+		err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	case "unfollow":
-		err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
+		err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+	case "block":
+		err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+		redirectViaJSON = true
+	case "unblock":
+		err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	}
 
 	if err != nil {
 		ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err)
 		return
 	}
+
+	if redirectViaJSON {
+		ctx.JSON(http.StatusOK, map[string]interface{}{
+			"redirect": ctx.ContextUser.HomeLink(),
+		})
+		return
+	}
+
 	// FIXME: We should check this URL and make sure that it's a valid Gitea URL
 	ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.ContextUser.HomeLink())
 }
diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go
new file mode 100644
index 0000000000..ea6ccf74d9
--- /dev/null
+++ b/routers/web/user/setting/blocked_users.go
@@ -0,0 +1,34 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"net/http"
+
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+const (
+	tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users"
+)
+
+// BlockedUsers render the blocked users list page.
+func BlockedUsers(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
+	ctx.Data["PageIsBlockedUsers"] = true
+	ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users"
+	ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users"
+
+	blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID)
+	if err != nil {
+		ctx.ServerError("ListBlockedUsers", err)
+		return
+	}
+
+	ctx.Data["BlockedUsers"] = blockedUsers
+	ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index c1b7649b4b..1a6ecb2c21 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -520,6 +520,8 @@ func registerRoutes(m *web.Route) {
 			})
 			addWebhookEditRoutes()
 		}, webhooksEnabled)
+
+		m.Get("/blocked_users", user_setting.BlockedUsers)
 	}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
 
 	m.Group("/user", func() {
diff --git a/services/issue/comments.go b/services/issue/comments.go
index 4a181499bc..6bc4453aa0 100644
--- a/services/issue/comments.go
+++ b/services/issue/comments.go
@@ -66,6 +66,11 @@ func CreateRefComment(doer *user_model.User, repo *repo_model.Repository, issue
 
 // CreateIssueComment creates a plain issue comment.
 func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) {
+	// Check if doer is blocked by the poster of the issue.
+	if user_model.IsBlocked(ctx, issue.PosterID, doer.ID) {
+		return nil, user_model.ErrBlockedByUser
+	}
+
 	comment, err := CreateComment(ctx, &issues_model.CreateCommentOptions{
 		Type:        issues_model.CommentTypeComment,
 		Doer:        doer,
diff --git a/services/issue/issue.go b/services/issue/issue.go
index ce2b1c0976..0b2539c3fa 100644
--- a/services/issue/issue.go
+++ b/services/issue/issue.go
@@ -22,6 +22,11 @@ import (
 
 // NewIssue creates new issue with labels for repository.
 func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error {
+	// Check if the user is not blocked by the repo's owner.
+	if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) {
+		return user_model.ErrBlockedByUser
+	}
+
 	if err := issues_model.NewIssue(repo, issue, labelIDs, uuids); err != nil {
 		return err
 	}
diff --git a/services/issue/reaction.go b/services/issue/reaction.go
new file mode 100644
index 0000000000..dbb4735de2
--- /dev/null
+++ b/services/issue/reaction.go
@@ -0,0 +1,47 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package issue
+
+import (
+	"context"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	user_model "code.gitea.io/gitea/models/user"
+)
+
+// CreateIssueReaction creates a reaction on issue.
+func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) {
+	if err := issue.LoadRepo(ctx); err != nil {
+		return nil, err
+	}
+
+	// Check if the doer is blocked by the issue's poster or repository owner.
+	if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, issue.Repo.OwnerID}, doer.ID) {
+		return nil, user_model.ErrBlockedByUser
+	}
+
+	return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
+		Type:    content,
+		DoerID:  doer.ID,
+		IssueID: issue.ID,
+	})
+}
+
+// CreateCommentReaction creates a reaction on comment.
+func CreateCommentReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) {
+	if err := issue.LoadRepo(ctx); err != nil {
+		return nil, err
+	}
+
+	// Check if the doer is blocked by the issue's poster, the comment's poster or repository owner.
+	if user_model.IsBlockedMultiple(ctx, []int64{comment.PosterID, issue.PosterID, issue.Repo.OwnerID}, doer.ID) {
+		return nil, user_model.ErrBlockedByUser
+	}
+
+	return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
+		Type:      content,
+		DoerID:    doer.ID,
+		IssueID:   issue.ID,
+		CommentID: comment.ID,
+	})
+}
diff --git a/services/pull/pull.go b/services/pull/pull.go
index 8f2befa8ff..26107096de 100644
--- a/services/pull/pull.go
+++ b/services/pull/pull.go
@@ -36,6 +36,11 @@ var pullWorkingPool = sync.NewExclusivePool()
 
 // NewPullRequest creates new pull request with labels for repository.
 func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error {
+	// Check if the doer is not blocked by the repository's owner.
+	if user_model.IsBlocked(ctx, repo.OwnerID, pull.PosterID) {
+		return user_model.ErrBlockedByUser
+	}
+
 	if err := TestPatch(pr); err != nil {
 		return err
 	}
diff --git a/services/user/block.go b/services/user/block.go
new file mode 100644
index 0000000000..eff3242784
--- /dev/null
+++ b/services/user/block.go
@@ -0,0 +1,40 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package user
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/models/db"
+	user_model "code.gitea.io/gitea/models/user"
+)
+
+// BlockUser adds a blocked user entry for userID to block blockID.
+// TODO: Figure out if instance admins should be immune to blocking.
+// TODO: Add more mechanism like removing blocked user as collaborator on
+// repositories where the user is an owner.
+func BlockUser(ctx context.Context, userID, blockID int64) error {
+	if userID == blockID || user_model.IsBlocked(ctx, userID, blockID) {
+		return nil
+	}
+
+	ctx, committer, err := db.TxContext(ctx)
+	if err != nil {
+		return err
+	}
+	defer committer.Close()
+
+	// Add the blocked user entry.
+	_, err = db.GetEngine(ctx).Insert(&user_model.BlockedUser{UserID: userID, BlockID: blockID})
+	if err != nil {
+		return err
+	}
+
+	// Unfollow the user from block's perspective.
+	err = user_model.UnfollowUser(ctx, blockID, userID)
+	if err != nil {
+		return err
+	}
+
+	return committer.Commit()
+}
diff --git a/services/user/delete.go b/services/user/delete.go
index 01e3c37b39..0f33d712a4 100644
--- a/services/user/delete.go
+++ b/services/user/delete.go
@@ -90,6 +90,8 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
 		&pull_model.AutoMerge{DoerID: u.ID},
 		&pull_model.ReviewState{UserID: u.ID},
 		&user_model.Redirect{RedirectUserID: u.ID},
+		&user_model.BlockedUser{BlockID: u.ID},
+		&user_model.BlockedUser{UserID: u.ID},
 	); err != nil {
 		return fmt.Errorf("deleteBeans: %w", err)
 	}
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl
index 51d4a46a92..58aab7167c 100644
--- a/templates/user/profile.tmpl
+++ b/templates/user/profile.tmpl
@@ -115,6 +115,21 @@
 									</form>
 								{{end}}
 							</li>
+							<li class="block">
+								{{if $.IsBlocked}}
+									<form method="post" action="{{.Link}}?action=unblock&redirect_to={{$.Link}}">
+										{{$.CsrfTokenHtml}}
+										<button type="submit" class="ui basic red button">{{svg "octicon-blocked"}} {{.locale.Tr "user.unblock"}}</button>
+									</form>
+								{{else}}
+									<form>
+										<button type="submit" class="ui basic orange button delete-button"
+										data-modal-id="block-user" data-url="{{.Link}}?action=block">
+											{{svg "octicon-blocked"}} {{.locale.Tr "user.block"}}
+										</button>
+									</form>
+								{{end}}
+							</li>
 							{{end}}
 						</ul>
 					</div>
@@ -156,4 +171,19 @@
 		</div>
 	</div>
 </div>
+<div class="ui small basic delete modal" id="block-user">
+	<div class="ui icon header">
+		{{svg "octicon-blocked" 16 "blocked inside"}}
+		{{$.locale.Tr "user.block_user"}}
+	</div>
+	<div class="content">
+		<p>{{$.locale.Tr "user.block_user.detail"}}</p>
+		<ul>
+				<li>{{$.locale.Tr "user.block_user.detail_1"}}</li>
+				<li>{{$.locale.Tr "user.block_user.detail_2"}}</li>
+		</ul>
+	</div>
+	{{template "base/modal_actions_confirm" .}}
+</div>
+
 {{template "base/footer" .}}
diff --git a/templates/user/settings/blocked_users.tmpl b/templates/user/settings/blocked_users.tmpl
new file mode 100644
index 0000000000..fd0cb07883
--- /dev/null
+++ b/templates/user/settings/blocked_users.tmpl
@@ -0,0 +1,16 @@
+{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings blocked_users")}}
+	<div class="user-setting-content">
+		<h4 class="ui top attached header">
+			{{.locale.Tr "settings.blocked_users"}}
+		</h4>
+		<div class="ui attached segment">
+			<div class="ui blocked-user list gt-mt-0">
+				{{range .BlockedUsers}}
+					<div class="item">
+						{{avatar $.Context . 28 "gt-mr-3"}}<a href="{{.HomeLink}}">{{.Name}}</a>
+					</div>
+				{{end}}
+			</div>
+		</div>
+	</div>
+{{template "user/settings/layout_footer" .}}
diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl
index 4ef2abeaab..e03ad7e9a9 100644
--- a/templates/user/settings/navbar.tmpl
+++ b/templates/user/settings/navbar.tmpl
@@ -48,5 +48,8 @@
 		<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
 			{{.locale.Tr "settings.repos"}}
 		</a>
+		<a class="{{if .PageIsBlockedUsers}}active {{end}}item" href="{{AppSubUrl}}/user/settings/blocked_users">
+			{{.locale.Tr "settings.blocked_users"}}
+		</a>
 	</div>
 </div>
diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go
new file mode 100644
index 0000000000..03a5b14712
--- /dev/null
+++ b/tests/integration/block_test.go
@@ -0,0 +1,158 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"strconv"
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	issue_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/translation"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
+	t.Helper()
+
+	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
+
+	session := loginUser(t, doer.Name)
+	req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+		"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
+		"action": "block",
+	})
+	resp := session.MakeRequest(t, req, http.StatusOK)
+
+	type redirect struct {
+		Redirect string `json:"redirect"`
+	}
+
+	var respBody redirect
+	DecodeJSON(t, resp, &respBody)
+	assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect)
+	assert.EqualValues(t, true, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
+}
+
+func TestBlockUser(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	BlockUser(t, doer, blockedUser)
+
+	// Unblock user.
+	session := loginUser(t, doer.Name)
+	req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+		"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
+		"action": "unblock",
+	})
+	resp := session.MakeRequest(t, req, http.StatusSeeOther)
+
+	loc := resp.Header().Get("Location")
+	assert.EqualValues(t, "/"+blockedUser.Name, loc)
+	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
+}
+
+func TestBlockIssueCreation(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID})
+	BlockUser(t, doer, blockedUser)
+
+	session := loginUser(t, blockedUser.Name)
+	req := NewRequest(t, "GET", "/"+repo.OwnerName+"/"+repo.Name+"/issues/new")
+	resp := session.MakeRequest(t, req, http.StatusOK)
+
+	htmlDoc := NewHTMLParser(t, resp.Body)
+	link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
+	assert.True(t, exists)
+	req = NewRequestWithValues(t, "POST", link, map[string]string{
+		"_csrf":   htmlDoc.GetCSRF(),
+		"title":   "Title",
+		"content": "Hello!",
+	})
+
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	htmlDoc = NewHTMLParser(t, resp.Body)
+	assert.Contains(t,
+		htmlDoc.doc.Find(".ui.negative.message").Text(),
+		translation.NewLocale("en-US").Tr("repo.issues.blocked_by_user"),
+	)
+}
+
+func TestBlockIssueReaction(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+	issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, PosterID: doer.ID, RepoID: repo.ID})
+	issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), issue.Index)
+
+	BlockUser(t, doer, blockedUser)
+
+	session := loginUser(t, blockedUser.Name)
+	req := NewRequest(t, "GET", issueURL)
+	resp := session.MakeRequest(t, req, http.StatusOK)
+	htmlDoc := NewHTMLParser(t, resp.Body)
+
+	req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/react"), map[string]string{
+		"_csrf":   htmlDoc.GetCSRF(),
+		"content": "eyes",
+	})
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	type reactionResponse struct {
+		Empty bool `json:"empty"`
+	}
+
+	var respBody reactionResponse
+	DecodeJSON(t, resp, &respBody)
+
+	assert.EqualValues(t, true, respBody.Empty)
+}
+
+func TestBlockCommentReaction(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 1, RepoID: repo.ID})
+	comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 3, PosterID: doer.ID, IssueID: issue.ID})
+	_ = comment.LoadIssue(db.DefaultContext)
+	issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), issue.Index)
+
+	BlockUser(t, doer, blockedUser)
+
+	session := loginUser(t, blockedUser.Name)
+	req := NewRequest(t, "GET", issueURL)
+	resp := session.MakeRequest(t, req, http.StatusOK)
+	htmlDoc := NewHTMLParser(t, resp.Body)
+
+	req = NewRequestWithValues(t, "POST", path.Join(repo.Link(), "/comments/", strconv.FormatInt(comment.ID, 10), "/reactions/react"), map[string]string{
+		"_csrf":   htmlDoc.GetCSRF(),
+		"content": "eyes",
+	})
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	type reactionResponse struct {
+		Empty bool `json:"empty"`
+	}
+
+	var respBody reactionResponse
+	DecodeJSON(t, resp, &respBody)
+
+	assert.EqualValues(t, true, respBody.Empty)
+}
diff --git a/web_src/css/user.css b/web_src/css/user.css
index 54a966e7cb..71b4232b58 100644
--- a/web_src/css/user.css
+++ b/web_src/css/user.css
@@ -34,7 +34,11 @@
   margin-right: 5px;
 }
 
-.user.profile .ui.card .extra.content > ul > li.follow .ui.button {
+.user.profile .ui.card .extra.content > ul > li.follow .ui.button,
+.user.profile .ui.card .extra.content > ul > li.block .ui.button {
+  align-items: center;
+  display: flex;
+  justify-content: center;
   width: 100%;
 }