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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to
### Added

- ✨(backend) add a is_first_connection flag to the User model#1938
### Changed

- ✨(backend) add limit on distinct reactions per comment #1978

## [v4.7.0] - 2026-03-09

Expand Down
27 changes: 25 additions & 2 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2625,6 +2625,7 @@ def get(self, request):
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"REACTIONS_MAX_PER_COMMENT",
"SENTRY_DSN",
"TRASHBIN_CUTOFF_DAYS",
]
Expand Down Expand Up @@ -2742,7 +2743,9 @@ class CommentViewSet(
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.CommentSerializer
queryset = models.Comment.objects.select_related("user").all()
queryset = models.Comment.objects.select_related("user").prefetch_related(
"reactions__users"
).all()

def get_queryset(self):
"""Override to filter on related resource."""
Expand Down Expand Up @@ -2776,9 +2779,29 @@ def reactions(self, request, *args, **kwargs):
serializer.is_valid(raise_exception=True)

if request.method == "POST":
emoji = serializer.validated_data["emoji"]

if (
not models.Reaction.objects.filter(
comment=comment, emoji=emoji
).exists()
and comment.reactions.count() >= settings.REACTIONS_MAX_PER_COMMENT
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably add reactions in the select_related objects in the queryset property to avoid N+1 query.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it to

queryset = (
    models.Comment.objects.select_related("user")
    .prefetch_related("reactions__users")
    .all()
)

This loads each comment’s user, its related reactions, and the users who reacted.

):
return drf.response.Response(
{
"emoji": [
_(
"A comment can have a maximum of %(max)d distinct reactions."
)
% {"max": settings.REACTIONS_MAX_PER_COMMENT}
]
},
status=status.HTTP_400_BAD_REQUEST,
)

reaction, created = models.Reaction.objects.get_or_create(
comment=comment,
emoji=serializer.validated_data["emoji"],
emoji=emoji,
)
if not created and reaction.users.filter(id=request.user.id).exists():
return drf.response.Response(
Expand Down
5 changes: 5 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ class Meta:
comment = factory.SubFactory(CommentFactory)
emoji = "test"

@classmethod
def generate_emojis(cls, n=10):
"""Generate a list of n unique emojis."""
return [fake.unique.emoji() for _ in range(n)]

@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to reaction from a given list of users or create one if not provided."""
Expand Down
55 changes: 55 additions & 0 deletions src/backend/core/tests/documents/test_api_documents_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import pytest
from rest_framework.test import APIClient

from django.conf import settings

from core import factories, models

pytestmark = pytest.mark.django_db
Expand Down Expand Up @@ -876,3 +878,56 @@ def test_delete_reaction_owned_by_the_current_user():

reaction.refresh_from_db()
assert reaction.users.exists()


def test_create_reaction_exceeds_maximum():
"""
Users should not be able to add more than REACTIONS_MAX_PER_COMMENT
(here we set it to 10) distinct emoji reactions to a comment.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see where you set it to 10.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was hardcoded. I’ve imported settings in the test files and used the REACTIONS_MAX_PER_COMMENT var:

  max_reactions = settings.REACTIONS_MAX_PER_COMMENT

They should, however, be able to add themselves to an existing reaction.
"""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[(user1, models.RoleChoices.ADMIN), (user2, models.RoleChoices.ADMIN)],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)

client = APIClient()
client.force_login(user1)

# Add max distinct reactions
max_reactions = settings.REACTIONS_MAX_PER_COMMENT
emojis = factories.ReactionFactory.generate_emojis(max_reactions + 1)
for emoji in emojis[:max_reactions]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": emoji},
)
assert response.status_code == 201

# Attempt to add another distinct reaction
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": emojis[max_reactions]},
)
assert response.status_code == 400
expected_message = (
f"A comment can have a maximum of {max_reactions} distinct reactions."
)
assert response.json() == {"emoji": [expected_message]}

# Attempt to add user2 to one of the existing reactions (should succeed)
client.force_login(user2)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": emojis[0]},
)
assert response.status_code == 201
reaction = models.Reaction.objects.get(comment=comment, emoji=emojis[0])
assert reaction.users.count() == 2
6 changes: 6 additions & 0 deletions src/backend/impress/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ class Base(Configuration):
environ_prefix=None,
)

REACTIONS_MAX_PER_COMMENT = values.IntegerValue(
15,
environ_name="REACTIONS_MAX_PER_COMMENT",
environ_prefix=None,
)

DOCUMENT_UNSAFE_MIME_TYPES = [
# Executable Files
"application/x-msdownload",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface ConfigResponse {
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
REACTIONS_MAX_PER_COMMENT: number;
TRASHBIN_CUTOFF_DAYS?: number;
theme_customization?: ThemeCustomization;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth {
constructor(
private readonly userId: string,
public canSee: boolean,
private readonly maxReactions: number = 10,
) {
super();
}
Expand Down Expand Up @@ -68,13 +69,27 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth {
}

if (!emoji) {
return true;
return comment.reactions.length < this.maxReactions;
}

return !comment.reactions.some(
const hasReactedWithEmoji = comment.reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.userIds.includes(this.userId),
);

if (hasReactedWithEmoji) {
return false;
}

const reactionExists = comment.reactions.some(
(reaction) => reaction.emoji === emoji,
);

if (reactionExists) {
return true;
}

return comment.reactions.length < this.maxReactions;
}

canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useCunninghamTheme } from '@/cunningham';
import { User, avatarUrlFromName } from '@/features/auth';
import { Doc, useProviderStore } from '@/features/docs/doc-management';
import { useConfig } from '@/core';

import { DocsThreadStore } from './DocsThreadStore';
import { DocsThreadStoreAuth } from './DocsThreadStoreAuth';
Expand All @@ -16,6 +17,7 @@ export function useComments(
const { provider } = useProviderStore();
const { t } = useTranslation();
const { themeTokens } = useCunninghamTheme();
const { data: config } = useConfig();

const threadStore = useMemo(() => {
return new DocsThreadStore(
Expand All @@ -24,9 +26,16 @@ export function useComments(
new DocsThreadStoreAuth(
encodeURIComponent(user?.full_name || ''),
canComment,
config?.REACTIONS_MAX_PER_COMMENT,
),
);
}, [docId, canComment, provider?.awareness, user?.full_name]);
}, [
docId,
canComment,
provider?.awareness,
user?.full_name,
config?.REACTIONS_MAX_PER_COMMENT,
]);

useEffect(() => {
return () => {
Expand Down