diff --git a/models/activities/action.go b/models/activities/action.go
index 1bfd62438b..740a6f2d32 100644
--- a/models/activities/action.go
+++ b/models/activities/action.go
@@ -588,7 +588,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 7c794564b6..3ef5bc79c6 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/comment.yml b/models/fixtures/comment.yml
index bd64680c8c..28381eb4b0 100644
--- a/models/fixtures/comment.yml
+++ b/models/fixtures/comment.yml
@@ -66,3 +66,12 @@
   tree_path: "README.md"
   created_unix: 946684812
   invalidated: true
+
+-
+  id: 8
+  type: 0 # comment
+  poster_id: 2
+  issue_id: 4 # in repo_id 2
+  content: "I just wanted to add.."
+  created_unix: 946684812
+  updated_unix: 946684812
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/fixtures/issue.yml b/models/fixtures/issue.yml
index ccc1fe41fb..0c9b6ff406 100644
--- a/models/fixtures/issue.yml
+++ b/models/fixtures/issue.yml
@@ -61,7 +61,7 @@
   priority: 0
   is_closed: true
   is_pull: false
-  num_comments: 0
+  num_comments: 1
   created_unix: 946684830
   updated_unix: 978307200
   is_locked: false
diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml
index 373c1caa62..bc63969789 100644
--- a/models/fixtures/repository.yml
+++ b/models/fixtures/repository.yml
@@ -37,7 +37,7 @@
   lower_name: repo2
   name: repo2
   default_branch: master
-  num_watches: 0
+  num_watches: 1
   num_stars: 1
   num_forks: 0
   num_issues: 2
diff --git a/models/fixtures/watch.yml b/models/fixtures/watch.yml
index 1950ac99e7..c6c9726cc8 100644
--- a/models/fixtures/watch.yml
+++ b/models/fixtures/watch.yml
@@ -27,3 +27,9 @@
   user_id: 11
   repo_id: 1
   mode: 3 # auto
+
+-
+  id: 6
+  user_id: 4
+  repo_id: 2
+  mode: 1 # normal
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
index 4e98442c11..4b1fa2cf43 100644
--- a/models/issues/issue_test.go
+++ b/models/issues/issue_test.go
@@ -305,6 +305,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("org17", "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 78f4657c44..a0cf92c3ad 100644
--- a/models/issues/issue_update.go
+++ b/models/issues/issue_update.go
@@ -619,9 +619,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)
 				}
@@ -655,8 +657,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 bb47cf24ca..d5448636fe 100644
--- a/models/issues/reaction.go
+++ b/models/issues/reaction.go
@@ -240,25 +240,6 @@ func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, erro
 	return reaction, nil
 }
 
-// CreateIssueReaction creates a reaction on issue.
-func CreateIssueReaction(ctx context.Context, doerID, issueID int64, content string) (*Reaction, error) {
-	return CreateReaction(ctx, &ReactionOptions{
-		Type:    content,
-		DoerID:  doerID,
-		IssueID: issueID,
-	})
-}
-
-// CreateCommentReaction creates a reaction on comment.
-func CreateCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) (*Reaction, error) {
-	return CreateReaction(ctx, &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 5dc8e1a5f3..eb59e36ecd 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(db.DefaultContext, doerID, issueID, content)
-	} else {
-		reaction, err = issues_model.CreateCommentReaction(db.DefaultContext, 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)
 }
diff --git a/models/repo/collaboration.go b/models/repo/collaboration.go
index 2018ae2a7d..ac113d7165 100644
--- a/models/repo/collaboration.go
+++ b/models/repo/collaboration.go
@@ -137,6 +137,19 @@ func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid in
 	})
 }
 
+// GetCollaboratorWithUser returns all collaborator IDs of collabUserID on
+// repositories of ownerID.
+func GetCollaboratorWithUser(ctx context.Context, ownerID, collabUserID int64) ([]int64, error) {
+	collabsID := make([]int64, 0, 8)
+	err := db.GetEngine(ctx).Table("collaboration").Select("collaboration.`id`").
+		Join("INNER", "repository", "repository.id = collaboration.repo_id").
+		Where("repository.`owner_id` = ?", ownerID).
+		And("collaboration.`user_id` = ?", collabUserID).
+		Find(&collabsID)
+
+	return collabsID, err
+}
+
 // IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
 func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
 	if repo.OwnerID == userID {
diff --git a/models/repo/collaboration_test.go b/models/repo/collaboration_test.go
index 38114c307f..ef8d884b9e 100644
--- a/models/repo/collaboration_test.go
+++ b/models/repo/collaboration_test.go
@@ -11,6 +11,7 @@ import (
 	access_model "code.gitea.io/gitea/models/perm/access"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -156,3 +157,23 @@ func TestRepo_GetCollaboration(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Nil(t, collab)
 }
+
+func TestGetCollaboratorWithUser(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	user16 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
+	user15 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+	user18 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18})
+
+	collabs, err := repo_model.GetCollaboratorWithUser(db.DefaultContext, user16.ID, user15.ID)
+	assert.NoError(t, err)
+	assert.Len(t, collabs, 2)
+	assert.EqualValues(t, 5, collabs[0])
+	assert.EqualValues(t, 7, collabs[1])
+
+	collabs, err = repo_model.GetCollaboratorWithUser(db.DefaultContext, user16.ID, user18.ID)
+	assert.NoError(t, err)
+	assert.Len(t, collabs, 2)
+	assert.EqualValues(t, 6, collabs[0])
+	assert.EqualValues(t, 8, collabs[1])
+}
diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go
index dd2ef62201..5d6e24e2a5 100644
--- a/models/repo/user_repo.go
+++ b/models/repo/user_repo.go
@@ -177,3 +177,16 @@ func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull boo
 		Limit(30).
 		Find(&users)
 }
+
+// GetWatchedRepoIDsOwnedBy returns the repos owned by a particular user watched by a particular user
+func GetWatchedRepoIDsOwnedBy(ctx context.Context, userID, ownedByUserID int64) ([]int64, error) {
+	repoIDs := make([]int64, 0, 10)
+	err := db.GetEngine(ctx).
+		Table("repository").
+		Select("`repository`.id").
+		Join("LEFT", "watch", "`repository`.id=`watch`.repo_id").
+		Where("`watch`.user_id=?", userID).
+		And("`watch`.mode<>?", WatchModeDont).
+		And("`repository`.owner_id=?", ownedByUserID).Find(&repoIDs)
+	return repoIDs, err
+}
diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go
index 7816b0262a..ad794beb9b 100644
--- a/models/repo/user_repo_test.go
+++ b/models/repo/user_repo_test.go
@@ -9,6 +9,7 @@ import (
 	"code.gitea.io/gitea/models/db"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -71,3 +72,15 @@ func TestRepoGetReviewers(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, reviewers, 1)
 }
+
+func GetWatchedRepoIDsOwnedBy(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9})
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+	repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(db.DefaultContext, user1.ID, user2.ID)
+	assert.NoError(t, err)
+	assert.Len(t, repoIDs, 1)
+	assert.EqualValues(t, 1, repoIDs[0])
+}
diff --git a/models/repo/watch.go b/models/repo/watch.go
index 02a94ecac0..ce07bfc0ab 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
@@ -184,3 +201,9 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error
 	}
 	return watchRepoMode(ctx, watch, WatchModeAuto)
 }
+
+// UnwatchRepos will unwatch the user from all given repositories.
+func UnwatchRepos(ctx context.Context, userID int64, repoIDs []int64) error {
+	_, err := db.GetEngine(ctx).Where("user_id=?", userID).In("repo_id", repoIDs).Delete(&Watch{})
+	return err
+}
diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go
index 1384d1e157..8b6ac450f2 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())
 
@@ -137,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) {
 	assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone))
 	unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0)
 }
+
+func TestUnwatchRepos(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
+
+	err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2})
+	assert.NoError(t, err)
+
+	unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
+	unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
+}
diff --git a/models/user/block.go b/models/user/block.go
new file mode 100644
index 0000000000..189cacc2a2
--- /dev/null
+++ b/models/user/block.go
@@ -0,0 +1,91 @@
+// 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
+}
+
+// CountBlockedUsers returns the number of users the user has blocked.
+func CountBlockedUsers(ctx context.Context, userID int64) (int64, error) {
+	return db.GetEngine(ctx).Where("user_id=?", userID).Count(&BlockedUser{})
+}
+
+// ListBlockedUsers returns the users that the user has blocked.
+// The created_unix field of the user struct is overridden by the creation_unix
+// field of blockeduser.
+func ListBlockedUsers(ctx context.Context, userID int64, opts db.ListOptions) ([]*User, error) {
+	sess := db.GetEngine(ctx).
+		Select("`forgejo_blocked_user`.created_unix, `user`.*").
+		Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id").
+		Where("`forgejo_blocked_user`.user_id=?", userID)
+
+	if opts.Page > 0 {
+		sess = db.SetSessionPagination(sess, &opts)
+		users := make([]*User, 0, opts.PageSize)
+
+		return users, sess.Find(&users)
+	}
+
+	users := make([]*User, 0, 8)
+	return users, sess.Find(&users)
+}
+
+// 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..629c0c975a
--- /dev/null
+++ b/models/user/block_test.go
@@ -0,0 +1,77 @@
+// 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, db.ListOptions{})
+	assert.NoError(t, err)
+	if assert.Len(t, blockedUsers, 1) {
+		assert.EqualValues(t, 1, blockedUsers[0].ID)
+		// The function returns the created Unix of the block, not that of the user.
+		assert.EqualValues(t, 1671607299, blockedUsers[0].CreatedUnix)
+	}
+}
+
+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])
+	}
+}
+
+func TestCountBlockedUsers(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	count, err := user_model.CountBlockedUsers(db.DefaultContext, 4)
+	assert.NoError(t, err)
+	assert.EqualValues(t, 1, count)
+
+	count, err = user_model.CountBlockedUsers(db.DefaultContext, 1)
+	assert.NoError(t, err)
+	assert.EqualValues(t, 0, count)
+}
diff --git a/models/user/follow.go b/models/user/follow.go
index f4dd2891ff..9c3283b888 100644
--- a/models/user/follow.go
+++ b/models/user/follow.go
@@ -34,6 +34,10 @@ func FollowUser(ctx context.Context, userID, followID int64) (err error) {
 		return nil
 	}
 
+	if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) {
+		return ErrBlockedByUser
+	}
+
 	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 971117482c..c0082f8927 100644
--- a/models/user/user_test.go
+++ b/models/user/user_test.go
@@ -457,6 +457,12 @@ func TestFollowUser(t *testing.T) {
 
 	assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
 
+	// Blocked user.
+	assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 1, 4))
+	assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 4, 1))
+	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 1, FollowID: 4})
+	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 4, FollowID: 1})
+
 	unittest.CheckConsistencyFor(t, &user_model.User{})
 }
 
diff --git a/modules/repository/collaborator.go b/modules/repository/collaborator.go
index f5cdc35045..4099a178f2 100644
--- a/modules/repository/collaborator.go
+++ b/modules/repository/collaborator.go
@@ -14,6 +14,10 @@ import (
 )
 
 func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error {
+	if user_model.IsBlocked(ctx, repo.OwnerID, u.ID) || user_model.IsBlocked(ctx, u.ID, repo.OwnerID) {
+		return user_model.ErrBlockedByUser
+	}
+
 	return db.WithTx(ctx, func(ctx context.Context) error {
 		collaboration := &repo_model.Collaboration{
 			RepoID: repo.ID,
diff --git a/modules/repository/collaborator_test.go b/modules/repository/collaborator_test.go
index 622f6abce4..e623dbdaa4 100644
--- a/modules/repository/collaborator_test.go
+++ b/modules/repository/collaborator_test.go
@@ -33,6 +33,33 @@ func TestRepository_AddCollaborator(t *testing.T) {
 	testSuccess(3, 4)
 }
 
+func TestRepository_AddCollaborator_IsBlocked(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	testSuccess := func(repoID, userID int64) {
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
+		assert.NoError(t, repo.LoadOwner(db.DefaultContext))
+		user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+
+		// Owner blocked user.
+		unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
+		assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
+		unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
+		_, err := db.DeleteByBean(db.DefaultContext, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
+		assert.NoError(t, err)
+
+		// User has owner blocked.
+		unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: userID, BlockID: repo.OwnerID})
+		assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
+		unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
+	}
+	// Ensure idempotency (public repository).
+	testSuccess(1, 4)
+	testSuccess(1, 4)
+	// Add collaborator to private repository.
+	testSuccess(3, 4)
+}
+
 func TestRepoPermissionPublicNonOrgRepo(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
diff --git a/modules/structs/moderation.go b/modules/structs/moderation.go
new file mode 100644
index 0000000000..c1e55085a7
--- /dev/null
+++ b/modules/structs/moderation.go
@@ -0,0 +1,13 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// BlockedUser represents a blocked user.
+type BlockedUser struct {
+	BlockID int64 `json:"block_id"`
+	// swagger:strfmt date-time
+	Created time.Time `json:"created_at"`
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 3f48ca5589..9fe4364311 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -595,6 +595,12 @@ joined_on = Joined on %s
 repositories = Repositories
 activity = Public Activity
 followers = Followers
+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.
+block_user.detail_3 = This user cannot add you as a collaborator, nor can you add them as a collaborator.
+follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you.
 starred = Starred Repositories
 watched = Watched Repositories
 code = Code
@@ -603,6 +609,8 @@ overview = Overview
 following = Following
 follow = Follow
 unfollow = Unfollow
+block = Block
+unblock = Unblock
 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
@@ -632,6 +640,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! (You can use Markdown)
@@ -901,6 +910,7 @@ hooks.desc = Add webhooks which will be triggered for <strong>all repositories</
 
 orgs_none = You are not a member of any organizations.
 repos_none = You do not own any repositories.
+blocked_users_none = You haven't blocked any users.
 
 delete_account = Delete Your Account
 delete_prompt = This operation will permanently delete your user account. It <strong>CANNOT</strong> be undone.
@@ -923,6 +933,10 @@ visibility.limited_tooltip = Visible only to authenticated users
 visibility.private = Private
 visibility.private_tooltip = Visible only to members of organizations you have joined
 
+blocked_since = Blocked since %s
+user_unblock_success = The user has been unblocked successfully.
+user_block_success = The user has been blocked successfully.
+
 [repo]
 new_repo_helper = A repository contains all project files, including revision history.  Already hosting one elsewhere? <a href="%s">Migrate repository.</a>
 owner = Owner
@@ -1677,6 +1691,8 @@ 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.
+issues.comment.blocked_by_user = You cannot create a comment on this issue because you are blocked by the repository owner or the poster of the issue.
 
 compare.compare_base = base
 compare.compare_head = compare
@@ -1756,6 +1772,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.
@@ -2121,6 +2138,8 @@ settings.add_collaborator_success = The collaborator has been added.
 settings.add_collaborator_inactive_user = Cannot add an inactive user as a collaborator.
 settings.add_collaborator_owner = Cannot add an owner as a collaborator.
 settings.add_collaborator_duplicate = The collaborator is already added to this repository.
+settings.add_collaborator_blocked_our = Cannot add the collaborator, because the repository owner has blocked them.
+settings.add_collaborator_blocked_them = Cannot add the collaborator, because they have blocked the repository owner.
 settings.delete_collaborator = Remove
 settings.collaborator_deletion = Remove Collaborator
 settings.collaborator_deletion_desc = Removing a collaborator will revoke their access to this repository. Continue?
@@ -2585,6 +2604,7 @@ team_access_desc = Repository access
 team_permission_desc = Permission
 team_unit_desc = Allow Access to Repository Sections
 team_unit_disabled = (Disabled)
+follow_blocked_user = You cannot follow this organisation because this organisation has blocked you.
 
 form.name_reserved = The organization name "%s" is reserved.
 form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name.
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 3a62d38f17..6008ae575e 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -1027,6 +1027,14 @@ func Routes() *web.Route {
 					Delete(user.DeleteHook)
 			}, reqWebhooksEnabled())
 
+			m.Group("", func() {
+				m.Get("/list_blocked", user.ListBlockedUsers)
+				m.Group("", func() {
+					m.Put("/block/{username}", user.BlockUser)
+					m.Put("/unblock/{username}", user.UnblockUser)
+				}, context_service.UserAssignmentAPI())
+			})
+
 			m.Group("/avatar", func() {
 				m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar)
 				m.Delete("", user.DeleteAvatar)
@@ -1466,6 +1474,14 @@ func Routes() *web.Route {
 				m.Delete("", org.DeleteAvatar)
 			}, reqToken(), reqOrgOwnership())
 			m.Get("/activities/feeds", org.ListOrgActivityFeeds)
+
+			m.Group("", func() {
+				m.Get("/list_blocked", org.ListBlockedUsers)
+				m.Group("", func() {
+					m.Put("/block/{username}", org.BlockUser)
+					m.Put("/unblock/{username}", org.UnblockUser)
+				}, context_service.UserAssignmentAPI())
+			}, reqToken(), reqOrgOwnership())
 		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true))
 		m.Group("/teams/{teamid}", func() {
 			m.Combo("").Get(reqToken(), org.GetTeam).
diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go
index 2594e4afbb..f8fdb1d243 100644
--- a/routers/api/v1/org/org.go
+++ b/routers/api/v1/org/org.go
@@ -5,6 +5,7 @@
 package org
 
 import (
+	"fmt"
 	"net/http"
 
 	activities_model "code.gitea.io/gitea/models/activities"
@@ -457,3 +458,99 @@ func ListOrgActivityFeeds(ctx *context.APIContext) {
 
 	ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
 }
+
+// ListBlockedUsers list the organization's blocked users.
+func ListBlockedUsers(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/list_blocked organization orgListBlockedUsers
+	// ---
+	// summary: List the organization's blocked users
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the org
+	//   type: string
+	//   required: true
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/BlockedUserList"
+
+	utils.ListUserBlockedUsers(ctx, ctx.ContextUser)
+}
+
+// BlockUser blocks a user from the organization.
+func BlockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /orgs/{org}/block/{username} organization orgBlockUser
+	// ---
+	// summary: Blocks a user from the organization
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the org
+	//   type: string
+	//   required: true
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	if ctx.ContextUser.IsOrganization() {
+		ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
+		return
+	}
+
+	utils.BlockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser)
+}
+
+// UnblockUser unblocks a user from the organization.
+func UnblockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /orgs/{org}/unblock/{username} organization orgUnblockUser
+	// ---
+	// summary: Unblock a user from the organization
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the org
+	//   type: string
+	//   required: true
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	if ctx.ContextUser.IsOrganization() {
+		ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
+		return
+	}
+
+	utils.UnblockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser)
+}
diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go
index 2538bcdbc6..20f6941e9a 100644
--- a/routers/api/v1/repo/collaborators.go
+++ b/routers/api/v1/repo/collaborators.go
@@ -160,6 +160,8 @@ func AddCollaborator(ctx *context.APIContext) {
 	//     "$ref": "#/responses/notFound"
 	//   "422":
 	//     "$ref": "#/responses/validationError"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	form := web.GetForm(ctx).(*api.AddCollaboratorOption)
 
@@ -179,7 +181,11 @@ func AddCollaborator(ctx *context.APIContext) {
 	}
 
 	if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil {
-		ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "AddCollaborator", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
+		}
 		return
 	}
 
diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go
index 156dc9c1ee..15482ed416 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"
@@ -688,7 +689,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 a24ef75ae6..8a5fc5cc76 100644
--- a/routers/api/v1/repo/issue_comment.go
+++ b/routers/api/v1/repo/issue_comment.go
@@ -380,7 +380,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 29c99184e7..2a42c71783 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
@@ -202,9 +204,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp
 
 	if isCreateType {
 		// PostIssueCommentReaction part
-		reaction, err := issues_model.CreateCommentReaction(ctx, 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{
@@ -418,9 +420,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i
 
 	if isCreateType {
 		// PostIssueReaction part
-		reaction, err := issues_model.CreateIssueReaction(ctx, 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 586f3385b1..ae9addd20c 100644
--- a/routers/api/v1/repo/pull.go
+++ b/routers/api/v1/repo/pull.go
@@ -422,7 +422,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/swagger/repo.go b/routers/api/v1/swagger/repo.go
index 3e23aa4d5a..263e335873 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -414,3 +414,10 @@ type swaggerRepoNewIssuePinsAllowed struct {
 	// in:body
 	Body api.NewIssuePinsAllowed `json:"body"`
 }
+
+// BlockedUserList
+// swagger:response BlockedUserList
+type swaggerBlockedUserList struct {
+	// in:body
+	Body []api.BlockedUser `json:"body"`
+}
diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go
index 5815ed4f0b..783cee8584 100644
--- a/routers/api/v1/user/follower.go
+++ b/routers/api/v1/user/follower.go
@@ -5,6 +5,7 @@
 package user
 
 import (
+	"errors"
 	"net/http"
 
 	user_model "code.gitea.io/gitea/models/user"
@@ -223,8 +224,14 @@ func Follow(ctx *context.APIContext) {
 	//     "$ref": "#/responses/empty"
 	//   "404":
 	//     "$ref": "#/responses/notFound"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "BlockedByUser", err)
+			return
+		}
 		ctx.Error(http.StatusInternalServerError, "FollowUser", err)
 		return
 	}
diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go
index 6359138369..47b95eed1b 100644
--- a/routers/api/v1/user/user.go
+++ b/routers/api/v1/user/user.go
@@ -5,6 +5,7 @@
 package user
 
 import (
+	"fmt"
 	"net/http"
 
 	activities_model "code.gitea.io/gitea/models/activities"
@@ -202,3 +203,84 @@ func ListUserActivityFeeds(ctx *context.APIContext) {
 
 	ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
 }
+
+// ListBlockedUsers list the authenticated user's blocked users.
+func ListBlockedUsers(ctx *context.APIContext) {
+	// swagger:operation GET /user/list_blocked user userListBlockedUsers
+	// ---
+	// summary: List the authenticated user's blocked users
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/BlockedUserList"
+
+	utils.ListUserBlockedUsers(ctx, ctx.Doer)
+}
+
+// BlockUser blocks a user from the doer.
+func BlockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /user/block/{username} user userBlockUser
+	// ---
+	// summary: Blocks a user from the doer.
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	if ctx.ContextUser.IsOrganization() {
+		ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
+		return
+	}
+
+	utils.BlockUser(ctx, ctx.Doer, ctx.ContextUser)
+}
+
+// UnblockUser unblocks a user from the doer.
+func UnblockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /user/unblock/{username} user userUnblockUser
+	// ---
+	// summary: Unblocks a user from the doer.
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	if ctx.ContextUser.IsOrganization() {
+		ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
+		return
+	}
+
+	utils.UnblockUser(ctx, ctx.Doer, ctx.ContextUser)
+}
diff --git a/routers/api/v1/utils/block.go b/routers/api/v1/utils/block.go
new file mode 100644
index 0000000000..187d69044e
--- /dev/null
+++ b/routers/api/v1/utils/block.go
@@ -0,0 +1,65 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package utils
+
+import (
+	"net/http"
+
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/context"
+	api "code.gitea.io/gitea/modules/structs"
+	user_service "code.gitea.io/gitea/services/user"
+)
+
+// ListUserBlockedUsers lists the blocked users of the provided doer.
+func ListUserBlockedUsers(ctx *context.APIContext, doer *user_model.User) {
+	count, err := user_model.CountBlockedUsers(ctx, doer.ID)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	blockedUsers, err := user_model.ListBlockedUsers(ctx, doer.ID, GetListOptions(ctx))
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	apiBlockedUsers := make([]*api.BlockedUser, len(blockedUsers))
+	for i, blockedUser := range blockedUsers {
+		apiBlockedUsers[i] = &api.BlockedUser{
+			BlockID: blockedUser.ID,
+			Created: blockedUser.CreatedUnix.AsTime(),
+		}
+		if err != nil {
+			ctx.InternalServerError(err)
+			return
+		}
+	}
+
+	ctx.SetTotalCountHeader(count)
+	ctx.JSON(http.StatusOK, apiBlockedUsers)
+}
+
+// BlockUser blocks the blockUser from the doer.
+func BlockUser(ctx *context.APIContext, doer, blockUser *user_model.User) {
+	err := user_service.BlockUser(ctx, doer.ID, blockUser.ID)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+// UnblockUser unblocks the blockUser from the doer.
+func UnblockUser(ctx *context.APIContext, doer, blockUser *user_model.User) {
+	err := user_model.UnblockUser(ctx, doer.ID, blockUser.ID)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/web/org/setting/blocked_users.go b/routers/web/org/setting/blocked_users.go
new file mode 100644
index 0000000000..9f0c868aa2
--- /dev/null
+++ b/routers/web/org/setting/blocked_users.go
@@ -0,0 +1,79 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"code.gitea.io/gitea/models/db"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/routers/utils"
+	user_service "code.gitea.io/gitea/services/user"
+)
+
+const tplBlockedUsers = "org/settings/blocked_users"
+
+// BlockedUsers renders the blocked users page.
+func BlockedUsers(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
+	ctx.Data["PageIsSettingsBlockedUsers"] = true
+
+	blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{})
+	if err != nil {
+		ctx.ServerError("ListBlockedUsers", err)
+		return
+	}
+
+	ctx.Data["BlockedUsers"] = blockedUsers
+
+	ctx.HTML(http.StatusOK, tplBlockedUsers)
+}
+
+// BlockedUsersBlock blocks a particular user from the organization.
+func BlockedUsersBlock(ctx *context.Context) {
+	uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname")))
+	u, err := user_model.GetUserByName(ctx, uname)
+	if err != nil {
+		ctx.ServerError("GetUserByName", err)
+		return
+	}
+
+	if u.IsOrganization() {
+		ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name))
+		return
+	}
+
+	if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil {
+		ctx.ServerError("BlockUser", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("settings.user_block_success"))
+	ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
+}
+
+// BlockedUsersUnblock unblocks a particular user from the organization.
+func BlockedUsersUnblock(ctx *context.Context) {
+	u, err := user_model.GetUserByID(ctx, ctx.FormInt64("user_id"))
+	if err != nil {
+		ctx.ServerError("GetUserByID", err)
+		return
+	}
+
+	if u.IsOrganization() {
+		ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name))
+		return
+	}
+
+	if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil {
+		ctx.ServerError("UnblockUser", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
+	ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
+}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 5bee8c76a9..c6a4b10306 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1215,7 +1215,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
 		}
@@ -3069,7 +3072,11 @@ func NewComment(ctx *context.Context) {
 
 	comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments)
 	if err != nil {
-		ctx.ServerError("CreateIssueComment", err)
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Flash.Error(ctx.Tr("repo.issues.comment.blocked_by_user"))
+		} else {
+			ctx.ServerError("CreateIssueComment", err)
+		}
 		return
 	}
 
@@ -3209,7 +3216,7 @@ func ChangeIssueReaction(ctx *context.Context) {
 
 	switch ctx.Params(":action") {
 	case "react":
-		reaction, err := issues_model.CreateIssueReaction(ctx, 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)
@@ -3311,7 +3318,7 @@ func ChangeCommentReaction(ctx *context.Context) {
 
 	switch ctx.Params(":action") {
 	case "react":
-		reaction, err := issues_model.CreateCommentReaction(ctx, 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 639c0c74d0..c6e1df062e 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -1430,7 +1430,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/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go
index e217697cc0..98ee1a5431 100644
--- a/routers/web/repo/setting/collaboration.go
+++ b/routers/web/repo/setting/collaboration.go
@@ -4,6 +4,7 @@
 package setting
 
 import (
+	"errors"
 	"net/http"
 	"strings"
 
@@ -102,7 +103,18 @@ func CollaborationPost(ctx *context.Context) {
 	}
 
 	if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil {
-		ctx.ServerError("AddCollaborator", err)
+		if !errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.ServerError("AddCollaborator", err)
+			return
+		}
+
+		// To give an good error message, be precise on who has blocked who.
+		if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs {
+			ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our"))
+		} else {
+			ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them"))
+		}
+		ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
 		return
 	}
 
diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go
index 066d2ef2a9..1ed6858b99 100644
--- a/routers/web/repo/setting/settings_test.go
+++ b/routers/web/repo/setting/settings_test.go
@@ -103,13 +103,15 @@ func TestCollaborationPost(t *testing.T) {
 	ctx.Req.Form.Set("collaborator", "user4")
 
 	u := &user_model.User{
+		ID:        2,
 		LowerName: "user2",
 		Type:      user_model.UserTypeIndividual,
 	}
 
 	re := &repo_model.Repository{
-		ID:    2,
-		Owner: u,
+		ID:      2,
+		Owner:   u,
+		OwnerID: u.ID,
 	}
 
 	repo := &context.Repository{
@@ -161,13 +163,15 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) {
 	ctx.Req.Form.Set("collaborator", "user4")
 
 	u := &user_model.User{
+		ID:        2,
 		LowerName: "user2",
 		Type:      user_model.UserTypeIndividual,
 	}
 
 	re := &repo_model.Repository{
-		ID:    2,
-		Owner: u,
+		ID:      2,
+		Owner:   u,
+		OwnerID: u.ID,
 	}
 
 	repo := &context.Repository{
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
index 355f2ddeff..af5d3415db 100644
--- a/routers/web/shared/user/header.go
+++ b/routers/web/shared/user/header.go
@@ -31,6 +31,7 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) {
 	prepareContextForCommonProfile(ctx)
 
 	ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+	ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate
 	ctx.Data["UserLocationMapURL"] = setting.Service.UserLocationMapURL
 
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 48a4b94c19..1166e2f2de 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -5,6 +5,7 @@
 package user
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -23,6 +24,7 @@ import (
 	"code.gitea.io/gitea/routers/web/feed"
 	"code.gitea.io/gitea/routers/web/org"
 	shared_user "code.gitea.io/gitea/routers/web/shared/user"
+	user_service "code.gitea.io/gitea/services/user"
 )
 
 // OwnerProfile render profile page for a user or a organization (aka, repo owner)
@@ -290,16 +292,45 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi
 // Action response for follow/unfollow user request
 func Action(ctx *context.Context) {
 	var err error
-	switch ctx.FormString("action") {
+	var redirectViaJSON bool
+	action := ctx.FormString("action")
+
+	if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") {
+		log.Error("Cannot perform this action on an organization %q", ctx.FormString("action"))
+		ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+		return
+	}
+
+	switch action {
 	case "follow":
 		err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	case "unfollow":
 		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 {
-		log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
-		ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+		if !errors.Is(err, user_model.ErrBlockedByUser) {
+			log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
+			ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+			return
+		}
+
+		if ctx.ContextUser.IsOrganization() {
+			ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"))
+		} else {
+			ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"))
+		}
+	}
+
+	if redirectViaJSON {
+		ctx.JSON(http.StatusOK, map[string]interface{}{
+			"redirect": ctx.ContextUser.HomeLink(),
+		})
 		return
 	}
 	ctx.JSONOK()
diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go
new file mode 100644
index 0000000000..ed1c340fb9
--- /dev/null
+++ b/routers/web/user/setting/blocked_users.go
@@ -0,0 +1,46 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"net/http"
+
+	"code.gitea.io/gitea/models/db"
+	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, db.ListOptions{})
+	if err != nil {
+		ctx.ServerError("ListBlockedUsers", err)
+		return
+	}
+
+	ctx.Data["BlockedUsers"] = blockedUsers
+	ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
+}
+
+// UnblockUser unblocks a particular user for the doer.
+func UnblockUser(ctx *context.Context) {
+	if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil {
+		ctx.ServerError("UnblockUser", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
+	ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users")
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index a793cfb542..f6fffa6e44 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -629,6 +629,11 @@ func registerRoutes(m *web.Route) {
 			})
 			addWebhookEditRoutes()
 		}, webhooksEnabled)
+
+		m.Group("/blocked_users", func() {
+			m.Get("", user_setting.BlockedUsers)
+			m.Post("/unblock", user_setting.UnblockUser)
+		})
 	}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
 
 	m.Group("/user", func() {
@@ -886,6 +891,12 @@ func registerRoutes(m *web.Route) {
 
 				m.Methods("GET,POST", "/delete", org.SettingsDelete)
 
+				m.Group("/blocked_users", func() {
+					m.Get("", org_setting.BlockedUsers)
+					m.Post("/block", org_setting.BlockedUsersBlock)
+					m.Post("/unblock", org_setting.BlockedUsersUnblock)
+				})
+
 				m.Group("/packages", func() {
 					m.Get("", org.Packages)
 					m.Group("/rules", func() {
diff --git a/services/issue/comments.go b/services/issue/comments.go
index 8de085026e..cd17641090 100644
--- a/services/issue/comments.go
+++ b/services/issue/comments.go
@@ -46,6 +46,11 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod
 
 // 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 := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
 		Type:        issues_model.CommentTypeComment,
 		Doer:        doer,
diff --git a/services/issue/issue.go b/services/issue/issue.go
index b577fa189c..627c6d4bce 100644
--- a/services/issue/issue.go
+++ b/services/issue/issue.go
@@ -24,6 +24,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(ctx, 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 8ef7499ad5..a90c0a412b 100644
--- a/services/pull/pull.go
+++ b/services/pull/pull.go
@@ -38,6 +38,11 @@ var pullWorkingPool = sync.NewExclusivePool()
 
 // NewPullRequest creates new pull request with labels for repository.
 func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *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, issue.PosterID) {
+		return user_model.ErrBlockedByUser
+	}
+
 	prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
 	if err != nil {
 		if !git_model.IsErrBranchNotExist(err) {
diff --git a/services/user/block.go b/services/user/block.go
new file mode 100644
index 0000000000..06cdd27176
--- /dev/null
+++ b/services/user/block.go
@@ -0,0 +1,70 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package user
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	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 the block's perspective.
+	err = user_model.UnfollowUser(ctx, blockID, userID)
+	if err != nil {
+		return err
+	}
+
+	// Unfollow the user from the doer's perspective.
+	err = user_model.UnfollowUser(ctx, userID, blockID)
+	if err != nil {
+		return err
+	}
+
+	// Blocked user unwatch all repository owned by the doer.
+	repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID)
+	if err != nil {
+		return err
+	}
+
+	err = repo_model.UnwatchRepos(ctx, blockID, repoIDs)
+	if err != nil {
+		return err
+	}
+
+	// Remove blocked user as collaborator from repositories the user owns as an
+	// individual.
+	collabsID, err := repo_model.GetCollaboratorWithUser(ctx, userID, blockID)
+	if err != nil {
+		return err
+	}
+
+	_, err = db.GetEngine(ctx).In("id", collabsID).Delete(&repo_model.Collaboration{})
+	if err != nil {
+		return err
+	}
+
+	return committer.Commit()
+}
diff --git a/services/user/block_test.go b/services/user/block_test.go
new file mode 100644
index 0000000000..245dd959b9
--- /dev/null
+++ b/services/user/block_test.go
@@ -0,0 +1,73 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// TestBlockUser will ensure that when you block a user, certain actions have
+// been taken, like unfollowing each other etc.
+func TestBlockUser(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+	t.Run("Follow", func(t *testing.T) {
+		defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
+
+		// Follow each other.
+		assert.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID))
+		assert.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID))
+
+		assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+		// Ensure they aren't following each other anymore.
+		assert.False(t, user_model.IsFollowing(db.DefaultContext, doer.ID, blockedUser.ID))
+		assert.False(t, user_model.IsFollowing(db.DefaultContext, blockedUser.ID, doer.ID))
+	})
+
+	t.Run("Watch", func(t *testing.T) {
+		defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
+
+		// Blocked user watch repository of doer.
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID})
+		assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true))
+
+		assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+		// Ensure blocked user isn't following doer's repository.
+		assert.False(t, repo_model.IsWatching(db.DefaultContext, blockedUser.ID, repo.ID))
+	})
+
+	t.Run("Collaboration", func(t *testing.T) {
+		doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
+		blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18})
+		repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22, OwnerID: doer.ID})
+		repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21, OwnerID: doer.ID})
+		defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
+
+		isBlockedUserCollab := func(repo *repo_model.Repository) bool {
+			isCollaborator, err := repo_model.IsCollaborator(db.DefaultContext, repo.ID, blockedUser.ID)
+			assert.NoError(t, err)
+			return isCollaborator
+		}
+
+		assert.True(t, isBlockedUserCollab(repo1))
+		assert.True(t, isBlockedUserCollab(repo2))
+
+		assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+		assert.False(t, isBlockedUserCollab(repo1))
+		assert.False(t, isBlockedUserCollab(repo2))
+	})
+}
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/org/home.tmpl b/templates/org/home.tmpl
index ff4f3a8b1a..bd18ca30b6 100644
--- a/templates/org/home.tmpl
+++ b/templates/org/home.tmpl
@@ -1,5 +1,10 @@
 {{template "base/head" .}}
 <div role="main" aria-label="{{.Title}}" class="page-content organization profile">
+	{{if .Flash}}
+		<div class="ui container gt-mb-5">
+			{{template "base/alert" .}}
+		</div>
+	{{end}}
 	<div class="ui container gt-df">
 		{{ctx.AvatarUtils.Avatar .Org 140 "org-avatar"}}
 		<div id="org-info">
diff --git a/templates/org/settings/blocked_users.tmpl b/templates/org/settings/blocked_users.tmpl
new file mode 100644
index 0000000000..4133a43c69
--- /dev/null
+++ b/templates/org/settings/blocked_users.tmpl
@@ -0,0 +1,21 @@
+{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked-users")}}
+<div class="org-setting-content">
+	<div class="ui attached segment">
+		<form class="ui form ignore-dirty gt-df gt-fw gt-gap-3" action="{{$.Link}}/block" method="post">
+			{{.CsrfTokenHtml}}
+			<input type="hidden" name="uid" value="">
+			<div class="ui left">
+				<div id="search-user-box" class="ui search">
+					<div class="ui input">
+						<input class="prompt" name="uname" placeholder="{{.locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required>
+					</div>
+				</div>
+			</div>
+			<button type="submit" class="ui red button">{{.locale.Tr "user.block"}}</button>
+		</form>
+	</div>
+	<div class="ui attached segment">
+		{{template "shared/blocked_users_list" dict "locale" .locale "BlockedUsers" .BlockedUsers}}
+	</div>
+</div>
+{{template "org/settings/layout_footer" .}}
diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl
index 64ae20f0a3..a46e6821ad 100644
--- a/templates/org/settings/navbar.tmpl
+++ b/templates/org/settings/navbar.tmpl
@@ -38,6 +38,9 @@
 			</div>
 		</details>
 		{{end}}
+			<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
+			{{.locale.Tr "settings.blocked_users"}}
+		</a>
 		<a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete">
 			{{ctx.Locale.Tr "org.settings.delete"}}
 		</a>
diff --git a/templates/shared/blocked_users_list.tmpl b/templates/shared/blocked_users_list.tmpl
new file mode 100644
index 0000000000..ba399159e3
--- /dev/null
+++ b/templates/shared/blocked_users_list.tmpl
@@ -0,0 +1,28 @@
+<div class="flex-list">
+	{{range .BlockedUsers}}
+		<div class="flex-item flex-item-center">
+			<div class="flex-item-leading">
+				{{ctx.AvatarUtils.Avatar . 48}}
+			</div>
+			<div class="flex-item-main">
+				<div class="flex-item-title">
+					{{template "shared/user/name" .}}
+				</div>
+				<div class="flex-item-body">
+					<span>{{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</span>
+				</div>
+			</div>
+			<div class="flex-item-trailing">
+				<form action="{{$.Link}}/unblock" method="post">
+					{{$.CsrfTokenHtml}}
+					<input type="hidden" name="user_id" value="{{.ID}}">
+					<button class="ui red button">{{$.locale.Tr "user.unblock"}}</button>
+				</form>
+			</div>
+		</div>
+	{{else}}
+		<div class="flex-item">
+			<span class="text grey italic">{{$.locale.Tr "settings.blocked_users_none"}}</span>
+		</div>
+	{{end}}
+</div>
diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl
index a637a9a5f9..e4d70fc36a 100644
--- a/templates/shared/user/profile_big_avatar.tmpl
+++ b/templates/shared/user/profile_big_avatar.tmpl
@@ -121,6 +121,18 @@
 					</button>
 				{{end}}
 			</li>
+			<li class="block">
+				{{if $.IsBlocked}}
+					<button class="ui basic red button link-action" data-url="{{.ContextUser.HomeLink}}?action=unblock&redirect_to={{$.Link}}">
+						{{svg "octicon-person"}} {{.locale.Tr "user.unblock"}}
+					</button>
+				{{else}}
+					<button type="submit" class="ui basic orange button delete-button"
+					data-modal-id="block-user" data-url="{{.ContextUser.HomeLink}}?action=block">
+						{{svg "octicon-blocked"}} {{.locale.Tr "user.block"}}
+					</button>
+				{{end}}
+			</li>
 			{{end}}
 		</ul>
 	</div>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 79c202bb13..be4e65a765 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -1806,6 +1806,45 @@
         }
       }
     },
+    "/orgs/{org}/block/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Blocks a user from the organization",
+        "operationId": "orgBlockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the org",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/orgs/{org}/hooks": {
       "get": {
         "produces": [
@@ -2200,6 +2239,44 @@
         }
       }
     },
+    "/orgs/{org}/list_blocked": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "List the organization's blocked users",
+        "operationId": "orgListBlockedUsers",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the org",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/BlockedUserList"
+          }
+        }
+      }
+    },
     "/orgs/{org}/members": {
       "get": {
         "produces": [
@@ -2691,6 +2768,45 @@
         }
       }
     },
+    "/orgs/{org}/unblock/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Unblock a user from the organization",
+        "operationId": "orgUnblockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the org",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/packages/{owner}": {
       "get": {
         "produces": [
@@ -4191,6 +4307,9 @@
           "204": {
             "$ref": "#/responses/empty"
           },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
           "404": {
             "$ref": "#/responses/notFound"
           },
@@ -14799,6 +14918,38 @@
         }
       }
     },
+    "/user/block/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Blocks a user from the doer.",
+        "operationId": "userBlockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/user/emails": {
       "get": {
         "produces": [
@@ -14976,6 +15127,9 @@
           "204": {
             "$ref": "#/responses/empty"
           },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
           "404": {
             "$ref": "#/responses/notFound"
           }
@@ -15451,6 +15605,37 @@
         }
       }
     },
+    "/user/list_blocked": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "List the authenticated user's blocked users",
+        "operationId": "userListBlockedUsers",
+        "parameters": [
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/BlockedUserList"
+          }
+        }
+      }
+    },
     "/user/orgs": {
       "get": {
         "produces": [
@@ -15861,6 +16046,38 @@
         }
       }
     },
+    "/user/unblock/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Unblocks a user from the doer.",
+        "operationId": "userUnblockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/users/search": {
       "get": {
         "produces": [
@@ -16824,6 +17041,23 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "BlockedUser": {
+      "type": "object",
+      "title": "BlockedUser represents a blocked user.",
+      "properties": {
+        "block_id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "BlockID"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "Created"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "Branch": {
       "description": "Branch represents a repository branch",
       "type": "object",
@@ -23142,6 +23376,15 @@
         }
       }
     },
+    "BlockedUserList": {
+      "description": "BlockedUserList",
+      "schema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/definitions/BlockedUser"
+        }
+      }
+    },
     "Branch": {
       "description": "Branch",
       "schema": {
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl
index 426b5f042a..7e6c24a136 100644
--- a/templates/user/profile.tmpl
+++ b/templates/user/profile.tmpl
@@ -1,6 +1,7 @@
 {{template "base/head" .}}
 <div role="main" aria-label="{{.Title}}" class="page-content user profile">
 	<div class="ui container">
+		{{template "base/alert" .}}
 		<div class="ui stackable grid">
 			<div class="ui four wide column">
 				{{template "shared/user/profile_big_avatar" .}}
@@ -39,4 +40,20 @@
 		</div>
 	</div>
 </div>
+
+<div class="ui g-modal-confirm delete modal" id="block-user">
+	<div class="header">
+		{{$.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>
+			<li>{{$.locale.Tr "user.block_user.detail_3"}}</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..b7a35311c5
--- /dev/null
+++ b/templates/user/settings/blocked_users.tmpl
@@ -0,0 +1,10 @@
+{{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">
+		{{template "shared/blocked_users_list" dict "locale" .locale "BlockedUsers" .BlockedUsers}}
+	</div>
+</div>
+{{template "user/settings/layout_footer" .}}
diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl
index a690d00352..8c5d60494d 100644
--- a/templates/user/settings/navbar.tmpl
+++ b/templates/user/settings/navbar.tmpl
@@ -51,5 +51,8 @@
 		<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
 			{{ctx.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/api_block_test.go b/tests/integration/api_block_test.go
new file mode 100644
index 0000000000..48ee51bffa
--- /dev/null
+++ b/tests/integration/api_block_test.go
@@ -0,0 +1,228 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPIUserBlock(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := "user4"
+	session := loginUser(t, user)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+	t.Run("BlockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/user2?token=%s", token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
+	})
+
+	t.Run("ListBlocked", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/list_blocked?token=%s", token))
+		resp := MakeRequest(t, req, http.StatusOK)
+
+		// One user just got blocked and the other one is defined in the fixtures.
+		assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
+
+		var blockedUsers []api.BlockedUser
+		DecodeJSON(t, resp, &blockedUsers)
+		assert.Len(t, blockedUsers, 2)
+		assert.EqualValues(t, 1, blockedUsers[0].BlockID)
+		assert.EqualValues(t, 2, blockedUsers[1].BlockID)
+	})
+
+	t.Run("UnblockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/user2?token=%s", token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
+	})
+
+	t.Run("Organization as target", func(t *testing.T) {
+		org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization})
+
+		t.Run("Block", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", org.Name, token))
+			MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+			unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: org.ID})
+		})
+
+		t.Run("Unblock", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/%s?token=%s", org.Name, token))
+			MakeRequest(t, req, http.StatusUnprocessableEntity)
+		})
+	})
+}
+
+func TestAPIOrgBlock(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := "user5"
+	org := "user6"
+	session := loginUser(t, user)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+	t.Run("BlockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+	})
+
+	t.Run("ListBlocked", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token))
+		resp := MakeRequest(t, req, http.StatusOK)
+
+		assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
+
+		var blockedUsers []api.BlockedUser
+		DecodeJSON(t, resp, &blockedUsers)
+		assert.Len(t, blockedUsers, 1)
+		assert.EqualValues(t, 2, blockedUsers[0].BlockID)
+	})
+
+	t.Run("UnblockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2?token=%s", org, token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+	})
+
+	t.Run("Organization as target", func(t *testing.T) {
+		targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization})
+
+		t.Run("Block", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/%s?token=%s", org, targetOrg.Name, token))
+			MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+			unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: targetOrg.ID})
+		})
+
+		t.Run("Unblock", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/%s?token=%s", org, targetOrg.Name, token))
+			MakeRequest(t, req, http.StatusUnprocessableEntity)
+		})
+	})
+
+	t.Run("Read scope token", func(t *testing.T) {
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+
+		t.Run("Write action", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token))
+			MakeRequest(t, req, http.StatusForbidden)
+
+			unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+		})
+
+		t.Run("Read action", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token))
+			MakeRequest(t, req, http.StatusOK)
+		})
+	})
+
+	t.Run("Not as owner", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		org := "user3"
+		user := "user4" // Part of org team with write perms.
+
+		session := loginUser(t, user)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+		t.Run("Block user", func(t *testing.T) {
+			req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token))
+			MakeRequest(t, req, http.StatusForbidden)
+
+			unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 3, BlockID: 2})
+		})
+
+		t.Run("Unblock user", func(t *testing.T) {
+			req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2?token=%s", org, token))
+			MakeRequest(t, req, http.StatusForbidden)
+		})
+
+		t.Run("List blocked users", func(t *testing.T) {
+			req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token))
+			MakeRequest(t, req, http.StatusForbidden)
+		})
+	})
+}
+
+// TestAPIBlock_AddCollaborator ensures that the doer and blocked user cannot
+// add each others as collaborators via the API.
+func TestAPIBlock_AddCollaborator(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user1 := "user10"
+	user2 := "user2"
+	perm := "write"
+	collabOption := &api.AddCollaboratorOption{Permission: &perm}
+
+	// User1 blocks User2.
+	session := loginUser(t, user1)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
+
+	req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", user2, token))
+	MakeRequest(t, req, http.StatusNoContent)
+	unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 10, BlockID: 2})
+
+	t.Run("BlockedUser Add Doer", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2})
+		session := loginUser(t, user2)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user2, repo.Name, user1, token), collabOption)
+		session.MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("Doer Add BlockedUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: 10})
+		session := loginUser(t, user1)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user1, repo.Name, user2, token), collabOption)
+		session.MakeRequest(t, req, http.StatusForbidden)
+	})
+}
diff --git a/tests/integration/api_nodeinfo_test.go b/tests/integration/api_nodeinfo_test.go
index 4cbd25f5de..fb35d72ac2 100644
--- a/tests/integration/api_nodeinfo_test.go
+++ b/tests/integration/api_nodeinfo_test.go
@@ -34,6 +34,6 @@ func TestNodeinfo(t *testing.T) {
 		assert.Equal(t, "gitea", nodeinfo.Software.Name)
 		assert.Equal(t, 25, nodeinfo.Usage.Users.Total)
 		assert.Equal(t, 20, nodeinfo.Usage.LocalPosts)
-		assert.Equal(t, 2, nodeinfo.Usage.LocalComments)
+		assert.Equal(t, 3, nodeinfo.Usage.LocalComments)
 	})
 }
diff --git a/tests/integration/api_user_follow_test.go b/tests/integration/api_user_follow_test.go
index 62717af90e..bf6560b103 100644
--- a/tests/integration/api_user_follow_test.go
+++ b/tests/integration/api_user_follow_test.go
@@ -19,7 +19,7 @@ func TestAPIFollow(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 
 	user1 := "user4"
-	user2 := "user1"
+	user2 := "user10"
 
 	session1 := loginUser(t, user1)
 	token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser)
diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go
new file mode 100644
index 0000000000..fee6d4b6f9
--- /dev/null
+++ b/tests/integration/block_test.go
@@ -0,0 +1,369 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"strconv"
+	"testing"
+
+	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"
+	forgejo_context "code.gitea.io/gitea/modules/context"
+	"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.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
+}
+
+// TestBlockUser ensures that users can execute blocking related actions can
+// happen under the correct conditions.
+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})
+	session := loginUser(t, doer.Name)
+
+	t.Run("Block", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		BlockUser(t, doer, blockedUser)
+	})
+
+	// Unblock user.
+	t.Run("Unblock", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+			"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
+			"action": "unblock",
+		})
+		session.MakeRequest(t, req, http.StatusOK)
+
+		unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
+	})
+
+	t.Run("Organization as target", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+
+		t.Run("Block", func(t *testing.T) {
+			req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{
+				"_csrf":  GetCSRF(t, session, "/"+targetOrg.Name),
+				"action": "block",
+			})
+			resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+			assert.Contains(t, resp.Body.String(), "Action \\\"block\\\" failed")
+		})
+
+		t.Run("Unblock", func(t *testing.T) {
+			req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{
+				"_csrf":  GetCSRF(t, session, "/"+targetOrg.Name),
+				"action": "unblock",
+			})
+			resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+			assert.Contains(t, resp.Body.String(), "Action \\\"unblock\\\" failed")
+		})
+	})
+}
+
+// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user.
+func TestBlockUserFromOrganization(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization})
+	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
+	session := loginUser(t, doer.Name)
+
+	t.Run("Block user", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
+			"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+			"uname": blockedUser.Name,
+		})
+		session.MakeRequest(t, req, http.StatusSeeOther)
+		assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}))
+	})
+
+	t.Run("Unblock user", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
+			"_csrf":   GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+			"user_id": strconv.FormatInt(blockedUser.ID, 10),
+		})
+		session.MakeRequest(t, req, http.StatusSeeOther)
+		unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
+	})
+
+	t.Run("Organization as target", func(t *testing.T) {
+		targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+
+		t.Run("Block", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
+				"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+				"uname": targetOrg.Name,
+			})
+			session.MakeRequest(t, req, http.StatusInternalServerError)
+			unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: targetOrg.ID})
+		})
+
+		t.Run("Unblock", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
+				"_csrf":   GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+				"user_id": strconv.FormatInt(targetOrg.ID, 10),
+			})
+			session.MakeRequest(t, req, http.StatusInternalServerError)
+		})
+	})
+}
+
+// TestBlockActions ensures that certain actions cannot be performed as a doer
+// and as a blocked user and are handled cleanly after the blocking has taken
+// place.
+func TestBlockActions(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})
+	repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID})
+	issue4 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, RepoID: repo2.ID})
+	issue4URL := fmt.Sprintf("/%s/issues/%d", repo2.FullName(), issue4.Index)
+	// NOTE: Sessions shouldn't be shared, because in some situations flash
+	// messages are persistent and that would interfere with accurate test
+	// results.
+
+	BlockUser(t, doer, blockedUser)
+
+	// Ensures that issue creation on doer's ownen repositories are blocked.
+	t.Run("Issue creation", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		session := loginUser(t, blockedUser.Name)
+		link := fmt.Sprintf("%s/issues/new", repo2.FullName())
+
+		req := NewRequestWithValues(t, "POST", link, map[string]string{
+			"_csrf":   GetCSRF(t, session, link),
+			"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"),
+		)
+	})
+
+	// Ensures that comment creation on doer's owned repositories and doer's
+	// posted issues are blocked.
+	t.Run("Comment creation", func(t *testing.T) {
+		expectedFlash := "error%3DYou%2Bcannot%2Bcreate%2Ba%2Bcomment%2Bon%2Bthis%2Bissue%2Bbecause%2Byou%2Bare%2Bblocked%2Bby%2Bthe%2Brepository%2Bowner%2Bor%2Bthe%2Bposter%2Bof%2Bthe%2Bissue."
+
+		t.Run("Blocked by repository owner", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			session := loginUser(t, blockedUser.Name)
+
+			req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/comments"), map[string]string{
+				"_csrf":   GetCSRF(t, session, issue4URL),
+				"content": "Not a kind comment",
+			})
+			session.MakeRequest(t, req, http.StatusOK)
+
+			flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+			assert.NotNil(t, flashCookie)
+			assert.EqualValues(t, expectedFlash, flashCookie.Value)
+		})
+
+		t.Run("Blocked by issue poster", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
+			issue15 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 15, RepoID: repo5.ID, PosterID: doer.ID})
+
+			session := loginUser(t, blockedUser.Name)
+			issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo5.OwnerName), url.PathEscape(repo5.Name), issue15.Index)
+
+			req := NewRequestWithValues(t, "POST", path.Join(issueURL, "/comments"), map[string]string{
+				"_csrf":   GetCSRF(t, session, issueURL),
+				"content": "Not a kind comment",
+			})
+			session.MakeRequest(t, req, http.StatusOK)
+
+			flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+			assert.NotNil(t, flashCookie)
+			assert.EqualValues(t, expectedFlash, flashCookie.Value)
+		})
+	})
+
+	// Ensures that reactions on doer's owned issues and doer's owned comments are
+	// blocked.
+	t.Run("Add a reaction", func(t *testing.T) {
+		type reactionResponse struct {
+			Empty bool `json:"empty"`
+		}
+
+		t.Run("On a issue", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			session := loginUser(t, blockedUser.Name)
+
+			req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/reactions/react"), map[string]string{
+				"_csrf":   GetCSRF(t, session, issue4URL),
+				"content": "eyes",
+			})
+			resp := session.MakeRequest(t, req, http.StatusOK)
+
+			var respBody reactionResponse
+			DecodeJSON(t, resp, &respBody)
+
+			assert.EqualValues(t, true, respBody.Empty)
+		})
+
+		t.Run("On a comment", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 8, PosterID: doer.ID, IssueID: issue4.ID})
+
+			session := loginUser(t, blockedUser.Name)
+
+			req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/comments/%d/reactions/react", repo2.FullName(), comment.ID), map[string]string{
+				"_csrf":   GetCSRF(t, session, issue4URL),
+				"content": "eyes",
+			})
+			resp := session.MakeRequest(t, req, http.StatusOK)
+
+			var respBody reactionResponse
+			DecodeJSON(t, resp, &respBody)
+
+			assert.EqualValues(t, true, respBody.Empty)
+		})
+	})
+
+	// Ensures that the doer and blocked user cannot follow each other.
+	t.Run("Follow", func(t *testing.T) {
+		// Sanity checks to make sure doing these tests are valid.
+		unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
+		unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
+
+		// Doer cannot follow blocked user.
+		t.Run("Doer follow blocked user", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			session := loginUser(t, doer.Name)
+
+			req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+				"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
+				"action": "follow",
+			})
+			session.MakeRequest(t, req, http.StatusOK)
+
+			flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+			assert.NotNil(t, flashCookie)
+			assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
+
+			// Assert it still doesn't exist.
+			unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
+		})
+
+		// Blocked user cannot follow doer.
+		t.Run("Blocked user follow doer", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			session := loginUser(t, blockedUser.Name)
+
+			req := NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{
+				"_csrf":  GetCSRF(t, session, "/"+doer.Name),
+				"action": "follow",
+			})
+			session.MakeRequest(t, req, http.StatusOK)
+
+			flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+			assert.NotNil(t, flashCookie)
+			assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
+
+			unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
+		})
+	})
+
+	// Ensures that the doer and blocked user cannot add each each other as collaborators.
+	t.Run("Add collaborator", func(t *testing.T) {
+		blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
+
+		BlockUser(t, doer, blockedUser)
+
+		t.Run("Doer Add BlockedUser", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			session := loginUser(t, doer.Name)
+			link := fmt.Sprintf("/%s/settings/collaboration", repo2.FullName())
+
+			req := NewRequestWithValues(t, "POST", link, map[string]string{
+				"_csrf":        GetCSRF(t, session, link),
+				"collaborator": blockedUser.Name,
+			})
+			session.MakeRequest(t, req, http.StatusSeeOther)
+
+			flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+			assert.NotNil(t, flashCookie)
+			assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthe%2Brepository%2Bowner%2Bhas%2Bblocked%2Bthem.", flashCookie.Value)
+		})
+
+		t.Run("BlockedUser Add doer", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: blockedUser.ID})
+
+			session := loginUser(t, blockedUser.Name)
+			link := fmt.Sprintf("/%s/settings/collaboration", repo.FullName())
+
+			req := NewRequestWithValues(t, "POST", link, map[string]string{
+				"_csrf":        GetCSRF(t, session, link),
+				"collaborator": doer.Name,
+			})
+			session.MakeRequest(t, req, http.StatusSeeOther)
+
+			flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+			assert.NotNil(t, flashCookie)
+			assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthey%2Bhave%2Bblocked%2Bthe%2Brepository%2Bowner.", flashCookie.Value)
+		})
+	})
+}
diff --git a/web_src/css/org.css b/web_src/css/org.css
index 061d30bef2..037acd596a 100644
--- a/web_src/css/org.css
+++ b/web_src/css/org.css
@@ -166,6 +166,22 @@
   border-bottom: 1px solid var(--color-secondary);
 }
 
+.organization.teams .repositories .item,
+.organization.teams .members .item {
+  padding: 10px 19px;
+}
+
+.organization.teams .repositories .item:not(:last-child),
+.organization.teams .members .item:not(:last-child) {
+  border-bottom: 1px solid var(--color-secondary);
+}
+
+.organization.teams .repositories .item .button,
+.organization.teams .members .item .button {
+  padding: 9px 10px;
+  margin: 0;
+}
+
 .org-team-navbar .active.item {
   background: var(--color-box-body) !important;
 }
diff --git a/web_src/css/user.css b/web_src/css/user.css
index af8a2f5adc..9157a53e7c 100644
--- a/web_src/css/user.css
+++ b/web_src/css/user.css
@@ -36,6 +36,19 @@
   width: 100%;
 }
 
+.user.profile .ui.card .extra.content > ul > li .svg {
+  margin-left: 1px;
+  margin-right: 5px;
+}
+
+.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%;
+}
+
 .user.profile .ui.card #profile-avatar {
   padding: 1rem 1rem 0.25rem;
   justify-content: center;