Skip to content

Commit af0eeff

Browse files
committed
✨(backend) add limit on distinct reactions per comment
Implement a configurable limit (default: 15) on the number of distinct emoji reactions per comment. - Backend validation ensures the limit cannot be exceeded via API - Frontend disables reaction buttons when limit is reached Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
1 parent 1673752 commit af0eeff

File tree

8 files changed

+118
-4
lines changed

8 files changed

+118
-4
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- ✨(backend) add limit on distinct reactions per comment #1978
12+
913
## [v4.7.0] - 2026-03-09
1014

1115
### Added

src/backend/core/api/viewsets.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2605,6 +2605,7 @@ def get(self, request):
26052605
"POSTHOG_KEY",
26062606
"LANGUAGES",
26072607
"LANGUAGE_CODE",
2608+
"REACTIONS_MAX_PER_COMMENT",
26082609
"SENTRY_DSN",
26092610
"TRASHBIN_CUTOFF_DAYS",
26102611
]
@@ -2756,9 +2757,29 @@ def reactions(self, request, *args, **kwargs):
27562757
serializer.is_valid(raise_exception=True)
27572758

27582759
if request.method == "POST":
2760+
emoji = serializer.validated_data["emoji"]
2761+
2762+
if (
2763+
not models.Reaction.objects.filter(
2764+
comment=comment, emoji=emoji
2765+
).exists()
2766+
and comment.reactions.count() >= settings.REACTIONS_MAX_PER_COMMENT
2767+
):
2768+
return drf.response.Response(
2769+
{
2770+
"emoji": [
2771+
_(
2772+
"A comment can have a maximum of %(max)d distinct reactions."
2773+
)
2774+
% {"max": settings.REACTIONS_MAX_PER_COMMENT}
2775+
]
2776+
},
2777+
status=status.HTTP_400_BAD_REQUEST,
2778+
)
2779+
27592780
reaction, created = models.Reaction.objects.get_or_create(
27602781
comment=comment,
2761-
emoji=serializer.validated_data["emoji"],
2782+
emoji=emoji,
27622783
)
27632784
if not created and reaction.users.filter(id=request.user.id).exists():
27642785
return drf.response.Response(

src/backend/core/factories.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,11 @@ class Meta:
235235
comment = factory.SubFactory(CommentFactory)
236236
emoji = "test"
237237

238+
@classmethod
239+
def generate_emojis(cls, n=10):
240+
"""Generate a list of n unique emojis."""
241+
return [fake.unique.emoji() for _ in range(n)]
242+
238243
@factory.post_generation
239244
def users(self, create, extracted, **kwargs):
240245
"""Add users to reaction from a given list of users or create one if not provided."""

src/backend/core/tests/documents/test_api_documents_comments.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,3 +876,56 @@ def test_delete_reaction_owned_by_the_current_user():
876876

877877
reaction.refresh_from_db()
878878
assert reaction.users.exists()
879+
880+
881+
def test_create_reaction_exceeds_maximum():
882+
"""
883+
Users should not be able to add more than REACTIONS_MAX_PER_COMMENT
884+
(here we set it to 10) distinct emoji reactions to a comment.
885+
They should, however, be able to add themselves to an existing reaction.
886+
"""
887+
user1 = factories.UserFactory()
888+
user2 = factories.UserFactory()
889+
document = factories.DocumentFactory(
890+
link_reach="restricted",
891+
users=[(user1, models.RoleChoices.ADMIN), (user2, models.RoleChoices.ADMIN)],
892+
)
893+
thread = factories.ThreadFactory(document=document)
894+
comment = factories.CommentFactory(thread=thread)
895+
896+
client = APIClient()
897+
client.force_login(user1)
898+
899+
# Add max distinct reactions
900+
max_reactions = 10
901+
emojis = factories.ReactionFactory.generate_emojis(max_reactions)
902+
for emoji in emojis:
903+
response = client.post(
904+
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
905+
f"comments/{comment.id!s}/reactions/",
906+
{"emoji": emoji},
907+
)
908+
assert response.status_code == 201
909+
910+
# Attempt to add another distinct reaction
911+
response = client.post(
912+
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
913+
f"comments/{comment.id!s}/reactions/",
914+
{"emoji": "limit_breaker"},
915+
)
916+
assert response.status_code == 400
917+
expected_message = (
918+
f"A comment can have a maximum of {max_reactions} distinct reactions."
919+
)
920+
assert response.json() == {"emoji": [expected_message]}
921+
922+
# Attempt to add user2 to one of the existing reactions (should succeed)
923+
client.force_login(user2)
924+
response = client.post(
925+
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
926+
f"comments/{comment.id!s}/reactions/",
927+
{"emoji": emojis[0]},
928+
)
929+
assert response.status_code == 201
930+
reaction = models.Reaction.objects.get(comment=comment, emoji=emojis[0])
931+
assert reaction.users.count() == 2

src/backend/impress/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ class Base(Configuration):
182182
environ_prefix=None,
183183
)
184184

185+
REACTIONS_MAX_PER_COMMENT = values.IntegerValue(
186+
15,
187+
environ_name="REACTIONS_MAX_PER_COMMENT",
188+
environ_prefix=None,
189+
)
190+
185191
DOCUMENT_UNSAFE_MIME_TYPES = [
186192
# Executable Files
187193
"application/x-msdownload",

src/frontend/apps/impress/src/core/config/api/useConfig.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface ConfigResponse {
4848
MEDIA_BASE_URL?: string;
4949
POSTHOG_KEY?: PostHogConf;
5050
SENTRY_DSN?: string;
51+
REACTIONS_MAX_PER_COMMENT: number;
5152
TRASHBIN_CUTOFF_DAYS?: number;
5253
theme_customization?: ThemeCustomization;
5354
}

src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth {
66
constructor(
77
private readonly userId: string,
88
public canSee: boolean,
9+
private readonly maxReactions: number = 10,
910
) {
1011
super();
1112
}
@@ -68,13 +69,27 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth {
6869
}
6970

7071
if (!emoji) {
71-
return true;
72+
return comment.reactions.length < this.maxReactions;
7273
}
7374

74-
return !comment.reactions.some(
75+
const hasReactedWithEmoji = comment.reactions.some(
7576
(reaction) =>
7677
reaction.emoji === emoji && reaction.userIds.includes(this.userId),
7778
);
79+
80+
if (hasReactedWithEmoji) {
81+
return false;
82+
}
83+
84+
const reactionExists = comment.reactions.some(
85+
(reaction) => reaction.emoji === emoji,
86+
);
87+
88+
if (reactionExists) {
89+
return true;
90+
}
91+
92+
return comment.reactions.length < this.maxReactions;
7893
}
7994

8095
canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean {

src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
44
import { useCunninghamTheme } from '@/cunningham';
55
import { User, avatarUrlFromName } from '@/features/auth';
66
import { Doc, useProviderStore } from '@/features/docs/doc-management';
7+
import { useConfig } from '@/core';
78

89
import { DocsThreadStore } from './DocsThreadStore';
910
import { DocsThreadStoreAuth } from './DocsThreadStoreAuth';
@@ -16,6 +17,7 @@ export function useComments(
1617
const { provider } = useProviderStore();
1718
const { t } = useTranslation();
1819
const { themeTokens } = useCunninghamTheme();
20+
const { data: config } = useConfig();
1921

2022
const threadStore = useMemo(() => {
2123
return new DocsThreadStore(
@@ -24,9 +26,16 @@ export function useComments(
2426
new DocsThreadStoreAuth(
2527
encodeURIComponent(user?.full_name || ''),
2628
canComment,
29+
config?.REACTIONS_MAX_PER_COMMENT,
2730
),
2831
);
29-
}, [docId, canComment, provider?.awareness, user?.full_name]);
32+
}, [
33+
docId,
34+
canComment,
35+
provider?.awareness,
36+
user?.full_name,
37+
config?.REACTIONS_MAX_PER_COMMENT,
38+
]);
3039

3140
useEffect(() => {
3241
return () => {

0 commit comments

Comments
 (0)