From 1d8cac54f2f40d9b8277967793882bd9ea8a8c4a Mon Sep 17 00:00:00 2001 From: lsabor Date: Sun, 15 Mar 2026 14:06:59 -0700 Subject: [PATCH 1/5] issue/3349/follow-all-tournament-questions closes #3349 sets up back end support for following/unfollowing all questions in a project add tests accordingly --- ...22_projectsubscription_follow_questions.py | 18 ++ projects/models.py | 1 + projects/services/subscriptions.py | 128 +++++++++-- projects/views/common.py | 16 +- .../test_services/test_follow_questions.py | 200 ++++++++++++++++++ 5 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 projects/migrations/0022_projectsubscription_follow_questions.py create mode 100644 tests/unit/test_projects/test_services/test_follow_questions.py diff --git a/projects/migrations/0022_projectsubscription_follow_questions.py b/projects/migrations/0022_projectsubscription_follow_questions.py new file mode 100644 index 0000000000..5bc4672cf1 --- /dev/null +++ b/projects/migrations/0022_projectsubscription_follow_questions.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.9 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0021_projectindex_project_index_projectindexpost"), + ] + + operations = [ + migrations.AddField( + model_name="projectsubscription", + name="follow_questions", + field=models.BooleanField(default=False), + ), + ] diff --git a/projects/models.py b/projects/models.py index ce8effb058..d85e87f106 100644 --- a/projects/models.py +++ b/projects/models.py @@ -520,6 +520,7 @@ class ProjectSubscription(TimeStampedModel): project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="subscriptions" ) + follow_questions = models.BooleanField(default=False) class Meta: constraints = [ diff --git a/projects/services/subscriptions.py b/projects/services/subscriptions.py index 8a460ccbc0..43c3b644eb 100644 --- a/projects/services/subscriptions.py +++ b/projects/services/subscriptions.py @@ -9,7 +9,7 @@ NotificationQuestionParams, send_news_category_notebook_publish_notification, ) -from posts.models import Post, Notebook +from posts.models import Post, PostSubscription, Notebook from projects.models import Project, ProjectSubscription from projects.permissions import ObjectPermission from questions.constants import QuestionStatus @@ -17,25 +17,125 @@ from users.models import User -@transaction.atomic -def subscribe_project(project: Project, user: User): - obj = ProjectSubscription( - project=project, - user=user, +def _create_default_question_subscriptions(user: User, post: Post): + """ + Create default PostSubscription records for a question post. + Matches the defaults used in the frontend's getInitialQuestionSubscriptions. + """ + + from posts.services.subscriptions import ( + create_subscription_cp_change, + create_subscription_milestone, + create_subscription_new_comments, + create_subscription_status_change, ) - try: - obj.save() - except IntegrityError: - # Skip if use has been already subscribed - return + return [ + create_subscription_new_comments(user=user, post=post, comments_frequency=1), + create_subscription_status_change(user=user, post=post), + create_subscription_milestone(user=user, post=post, milestone_step=0.2), + create_subscription_cp_change(user=user, post=post, cp_change_threshold=0.25), + ] + + +def follow_all_project_questions(project: Project, user: User): + """ + Follow all questions in a project with default subscription settings. + Skips posts the user already has non-global subscriptions on. + """ + + posts = ( + Post.objects.filter_projects(project) + .filter_permission(user=user) + .filter_questions() + ) + + # Find posts user already follows (has non-global subscriptions) + already_followed_post_ids = set( + PostSubscription.objects.filter( + user=user, + post__in=posts, + is_global=False, + ) + .values_list("post_id", flat=True) + .distinct() + ) + + for post in posts: + if post.pk in already_followed_post_ids: + continue + _create_default_question_subscriptions(user, post) + + +def unfollow_all_project_questions(project: Project, user: User): + """ + Remove all non-global post subscriptions for questions in a project. + """ + + posts = ( + Post.objects.filter_projects(project) + .filter_permission(user=user) + .filter_questions() + ) + PostSubscription.objects.filter( + user=user, + post__in=posts, + is_global=False, + ).delete() + + +def follow_new_project_post(post: Post, project: Project): + """ + Auto-follow a newly added post for all project subscribers + who have follow_questions enabled. + """ + + subscriptions = ProjectSubscription.objects.filter( + project=project, + follow_questions=True, + ).select_related("user") + + for subscription in subscriptions: + user = subscription.user + # Check user has permission to view the post + if not Post.objects.filter_permission(user=user).filter(pk=post.pk).exists(): + continue + # Skip if user already has subscriptions on this post + if PostSubscription.objects.filter( + user=user, post=post, is_global=False + ).exists(): + continue + _create_default_question_subscriptions(user, post) + + +@transaction.atomic +def subscribe_project(project: Project, user: User, follow_questions: bool = False): + project_subscription = ProjectSubscription.objects.filter( + project=project, user=user + ).first() + if project_subscription: + # Update follow_questions if subscription already exists + project_subscription.follow_questions = follow_questions + project_subscription.save() + else: + ProjectSubscription.objects.create( + project=project, + user=user, + follow_questions=follow_questions, + ) project.update_followers_count() project.save() + if follow_questions: + follow_all_project_questions(project, user) + @transaction.atomic -def unsubscribe_project(project: Project, user: User): +def unsubscribe_project(project: Project, user: User, unfollow_questions: bool = False): + if unfollow_questions: + unfollow_all_project_questions(project, user) + ProjectSubscription.objects.filter(project=project, user=user).delete() project.update_followers_count() @@ -120,3 +220,7 @@ def notify_post_added_to_project(post: Post, project: Project): notify_project_subscriptions_post_open( post, notebook=post.notebook, project=project ) + + # Auto-follow new questions for subscribers with follow_questions enabled + if post.question_id or post.conditional_id or post.group_of_questions_id: + follow_new_project_post(post, project) diff --git a/projects/views/common.py b/projects/views/common.py index 9e6a7bf0cb..5f65606e87 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -236,7 +236,11 @@ def tournament_by_slug_api_view(request: Request, slug: str): data["followers_count"] = obj.followers_count if request.user.is_authenticated: - data["is_subscribed"] = obj.subscriptions.filter(user=request.user).exists() + subscription = obj.subscriptions.filter(user=request.user).first() + data["is_subscribed"] = subscription is not None + data["follow_questions"] = ( + subscription.follow_questions if subscription else False + ) if obj.index_id: data["index_data"] = serialize_index_data(obj.index) @@ -397,7 +401,10 @@ def project_subscribe_api_view(request: Request, pk: str): qs = get_projects_qs(user=request.user) project = get_object_or_404(qs, pk=pk) - subscribe_project(project=project, user=request.user) + follow_questions = request.data.get("follow_questions", False) + subscribe_project( + project=project, user=request.user, follow_questions=follow_questions + ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -407,7 +414,10 @@ def project_unsubscribe_api_view(request: Request, pk: str): qs = get_projects_qs(user=request.user) project = get_object_or_404(qs, pk=pk) - unsubscribe_project(project=project, user=request.user) + unfollow_questions = request.data.get("unfollow_questions", False) + unsubscribe_project( + project=project, user=request.user, unfollow_questions=unfollow_questions + ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/tests/unit/test_projects/test_services/test_follow_questions.py b/tests/unit/test_projects/test_services/test_follow_questions.py new file mode 100644 index 0000000000..28b8424612 --- /dev/null +++ b/tests/unit/test_projects/test_services/test_follow_questions.py @@ -0,0 +1,200 @@ +from datetime import datetime, timezone as dt_timezone + +from posts.models import Post, PostSubscription +from projects.models import ProjectSubscription +from projects.permissions import ObjectPermission +from projects.services.subscriptions import ( + subscribe_project, + unsubscribe_project, + follow_new_project_post, +) +from questions.models import Question +from tests.unit.test_posts.factories import factory_post +from tests.unit.test_questions.factories import create_question +from tests.unit.test_users.factories import factory_user +from tests.unit.test_projects.factories import factory_project + + +def test_subscribe_with_follow_questions(user1): + """Subscribing with follow_questions=True creates PostSubscriptions for all questions.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + post1 = factory_post( + author=factory_user(), + default_project=project, + question=create_question( + question_type=Question.QuestionType.BINARY, + open_time=datetime(2024, 1, 1, tzinfo=dt_timezone.utc), + scheduled_close_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + scheduled_resolve_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + ), + ) + post1.update_pseudo_materialized_fields() + post2 = factory_post( + author=factory_user(), + default_project=project, + question=create_question( + question_type=Question.QuestionType.BINARY, + open_time=datetime(2024, 1, 1, tzinfo=dt_timezone.utc), + scheduled_close_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + scheduled_resolve_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + ), + ) + post2.update_pseudo_materialized_fields() + + subscribe_project(project=project, user=user1, follow_questions=True) + + # User should be subscribed to the project + sub = ProjectSubscription.objects.get(project=project, user=user1) + assert sub.follow_questions is True + + # User should have default subscriptions on both posts + for post in [post1, post2]: + post_subs = PostSubscription.objects.filter( + user=user1, post=post, is_global=False + ) + sub_types = set(post_subs.values_list("type", flat=True)) + assert sub_types == { + PostSubscription.SubscriptionType.NEW_COMMENTS, + PostSubscription.SubscriptionType.STATUS_CHANGE, + PostSubscription.SubscriptionType.MILESTONE, + PostSubscription.SubscriptionType.CP_CHANGE, + } + + +def test_subscribe_without_follow_questions(user1): + """Subscribing without follow_questions does not create PostSubscriptions.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + subscribe_project(project=project, user=user1, follow_questions=False) + + assert ProjectSubscription.objects.filter(project=project, user=user1).exists() + assert not PostSubscription.objects.filter(user=user1, is_global=False).exists() + + +def test_subscribe_follow_questions_skips_already_followed(user1): + """If user already follows a post, subscribing with follow_questions skips it.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + post = factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + # Manually follow the post first + PostSubscription.objects.create( + user=user1, + post=post, + type=PostSubscription.SubscriptionType.NEW_COMMENTS, + comments_frequency=5, + ) + + subscribe_project(project=project, user=user1, follow_questions=True) + + # Should still have only the original subscription (not overwritten with defaults) + post_subs = PostSubscription.objects.filter(user=user1, post=post, is_global=False) + assert post_subs.count() == 1 + assert post_subs.first().comments_frequency == 5 + + +def test_unsubscribe_with_unfollow_questions(user1): + """Unsubscribing with unfollow_questions=True removes all PostSubscriptions.""" + project = factory_project( + default_permission=ObjectPermission.FORECASTER, subscribers=[user1] + ) + post = factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + # Create some subscriptions + PostSubscription.objects.create( + user=user1, + post=post, + type=PostSubscription.SubscriptionType.STATUS_CHANGE, + ) + + unsubscribe_project(project=project, user=user1, unfollow_questions=True) + + assert not ProjectSubscription.objects.filter(project=project, user=user1).exists() + assert not PostSubscription.objects.filter( + user=user1, post=post, is_global=False + ).exists() + + +def test_unsubscribe_without_unfollow_questions(user1): + """Unsubscribing without unfollow_questions keeps PostSubscriptions.""" + project = factory_project( + default_permission=ObjectPermission.FORECASTER, subscribers=[user1] + ) + post = factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + PostSubscription.objects.create( + user=user1, + post=post, + type=PostSubscription.SubscriptionType.STATUS_CHANGE, + ) + + unsubscribe_project(project=project, user=user1, unfollow_questions=False) + + assert not ProjectSubscription.objects.filter(project=project, user=user1).exists() + assert PostSubscription.objects.filter( + user=user1, post=post, is_global=False + ).exists() + + +def test_follow_new_project_post(user1, user2): + """When a new post is added, users with follow_questions get auto-subscribed.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + + # user1 subscribes with follow_questions + subscribe_project(project=project, user=user1, follow_questions=True) + # user2 subscribes without follow_questions + subscribe_project(project=project, user=user2, follow_questions=False) + + # New post added to project + new_post = factory_post( + author=factory_user(), + default_project=project, + question=create_question( + question_type=Question.QuestionType.BINARY, + open_time=datetime(2024, 1, 1, tzinfo=dt_timezone.utc), + scheduled_close_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + scheduled_resolve_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + ), + ) + new_post.update_pseudo_materialized_fields() + + follow_new_project_post(new_post, project) + + # user1 should have subscriptions on the new post + assert PostSubscription.objects.filter( + user=user1, post=new_post, is_global=False + ).exists() + + # user2 should not + assert not PostSubscription.objects.filter( + user=user2, post=new_post, is_global=False + ).exists() + + +def test_resubscribe_updates_follow_questions(user1): + """Re-subscribing updates the follow_questions preference.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + + subscribe_project(project=project, user=user1, follow_questions=False) + sub = ProjectSubscription.objects.get(project=project, user=user1) + assert sub.follow_questions is False + + subscribe_project(project=project, user=user1, follow_questions=True) + sub.refresh_from_db() + assert sub.follow_questions is True From 28a51a54a8608e3ff3664d70ff272dec73e8ca64 Mon Sep 17 00:00:00 2001 From: lsabor Date: Sun, 15 Mar 2026 14:24:40 -0700 Subject: [PATCH 2/5] front end implementation --- front_end/messages/en.json | 10 +++ .../tournament/[slug]/actions.tsx | 14 +++- .../tournament_subscribe_button.tsx | 78 +++++++++++++---- .../components/tournament_subscribe_modal.tsx | 84 +++++++++++++++++++ .../services/api/projects/projects.server.ts | 11 ++- front_end/src/types/projects.ts | 1 + projects/services/subscriptions.py | 2 +- .../test_services/test_follow_questions.py | 2 +- 8 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 5197d7ba80..730c8ea1fa 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -913,6 +913,16 @@ "followModalStatusChanges": "Status changes", "unfollowModalTitle": "Are you sure?", "unfollowModalDescription": "You won't be notified about this question anymore", + "tournamentFollowModalTitle": "Follow Tournament", + "tournamentFollowModalDescription": "You'll receive notifications about new questions and final rankings.", + "tournamentFollowModalAlsoFollowQuestions": "Also follow all questions", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Automatically follow each question with default notification settings. Newly added questions will also be followed.", + "tournamentFollowModalSubmit": "Follow", + "tournamentUnfollowModalTitle": "Unfollow Tournament", + "tournamentUnfollowModalDescription": "You will no longer receive notifications about this tournament.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Also unfollow all questions", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Remove your follows from all questions in this tournament.", + "tournamentUnfollowModalSubmit": "Unfollow", "best": "best", "cmmButton": "Changed my mind", "resolutionScores": "Resolution Scores", diff --git a/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx b/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx index c1d71808e5..878fd74883 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx @@ -53,10 +53,16 @@ export async function updateMember( } } -export async function subscribeProject(projectId: number) { - return ServerProjectsApi.subscribe(projectId); +export async function subscribeProject( + projectId: number, + params?: { follow_questions?: boolean } +) { + return ServerProjectsApi.subscribe(projectId, params); } -export async function unsubscribeProject(projectId: number) { - return ServerProjectsApi.unsubscribe(projectId); +export async function unsubscribeProject( + projectId: number, + params?: { unfollow_questions?: boolean } +) { + return ServerProjectsApi.unsubscribe(projectId, params); } diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx index 6369c745d9..3599990e1a 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx @@ -8,6 +8,7 @@ import { subscribeProject, unsubscribeProject, } from "@/app/(main)/(tournaments)/tournament/[slug]/actions"; +import TournamentSubscribeModal from "@/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal"; import Button from "@/components/ui/button"; import { useModal } from "@/contexts/modal_context"; import { Tournament } from "@/types/projects"; @@ -23,41 +24,71 @@ const TournamentSubscribeButton: FC = ({ user, tournament }) => { const [isFollowing, setIsFollowing] = useState( () => tournament.is_subscribed ); + const [followQuestions, setFollowQuestions] = useState( + () => tournament.follow_questions ?? false + ); const { setCurrentModal } = useModal(); const [isLoading, setIsLoading] = useState(false); + const [modalMode, setModalMode] = useState<"follow" | "unfollow" | null>( + null + ); - const handleSubscribe = useCallback(async () => { + const handleFollowClick = useCallback(() => { if (!user) { setCurrentModal({ type: "signup" }); } else { - setIsLoading(true); + setModalMode("follow"); + } + }, [setCurrentModal, user]); + + const handleUnfollowClick = useCallback(() => { + setModalMode("unfollow"); + }, []); + const handleModalClose = useCallback(() => { + setModalMode(null); + }, []); + + const handleFollowSubmit = useCallback( + async (shouldFollowQuestions: boolean) => { + setIsLoading(true); try { - await subscribeProject(tournament.id); + await subscribeProject(tournament.id, { + follow_questions: shouldFollowQuestions, + }); setIsFollowing(true); + setFollowQuestions(shouldFollowQuestions); + setModalMode(null); } finally { setIsLoading(false); } - } - }, [setCurrentModal, tournament.id, user]); - - const handleUnsubscribe = useCallback(async () => { - setIsLoading(true); + }, + [tournament.id] + ); - try { - await unsubscribeProject(tournament.id); - setIsFollowing(false); - } finally { - setIsLoading(false); - } - }, [tournament.id]); + const handleUnfollowSubmit = useCallback( + async (shouldUnfollowQuestions: boolean) => { + setIsLoading(true); + try { + await unsubscribeProject(tournament.id, { + unfollow_questions: shouldUnfollowQuestions, + }); + setIsFollowing(false); + setFollowQuestions(false); + setModalMode(null); + } finally { + setIsLoading(false); + } + }, + [tournament.id] + ); return ( <> {user && isFollowing ? ( )} + + ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx new file mode 100644 index 0000000000..7c3344b8a8 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC, useState } from "react"; + +import BaseModal from "@/components/base_modal"; +import Button from "@/components/ui/button"; +import Checkbox from "@/components/ui/checkbox"; + +type Props = { + isOpen: boolean; + onClose: () => void; + mode: "follow" | "unfollow"; + defaultFollowQuestions?: boolean; + onSubmit: (followQuestions: boolean) => void; + isLoading: boolean; +}; + +const TournamentSubscribeModal: FC = ({ + isOpen, + onClose, + mode, + defaultFollowQuestions = false, + onSubmit, + isLoading, +}) => { + const t = useTranslations(); + const [followQuestions, setFollowQuestions] = useState(defaultFollowQuestions); + + const isFollow = mode === "follow"; + + return ( + onClose()} + label={ + isFollow + ? t("tournamentFollowModalTitle") + : t("tournamentUnfollowModalTitle") + } + className="max-w-sm" + > +

+ {isFollow + ? t("tournamentFollowModalDescription") + : t("tournamentUnfollowModalDescription")} +

+ +
+ +

+ {isFollow + ? t("tournamentFollowModalAlsoFollowQuestionsDescription") + : t("tournamentUnfollowModalAlsoUnfollowQuestionsDescription")} +

+
+ +
+ + +
+
+ ); +}; + +export default TournamentSubscribeModal; diff --git a/front_end/src/services/api/projects/projects.server.ts b/front_end/src/services/api/projects/projects.server.ts index 704e061761..00b3377119 100644 --- a/front_end/src/services/api/projects/projects.server.ts +++ b/front_end/src/services/api/projects/projects.server.ts @@ -33,12 +33,15 @@ class ServerProjectsApiClass extends ProjectsApi { ); } - async subscribe(projectId: number) { - return this.post(`/projects/${projectId}/subscribe/`, {}); + async subscribe(projectId: number, params?: { follow_questions?: boolean }) { + return this.post(`/projects/${projectId}/subscribe/`, params ?? {}); } - async unsubscribe(projectId: number) { - return this.post(`/projects/${projectId}/unsubscribe/`, {}); + async unsubscribe( + projectId: number, + params?: { unfollow_questions?: boolean } + ) { + return this.post(`/projects/${projectId}/unsubscribe/`, params ?? {}); } async updateCommunity( diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts index 991278214a..9a8e303111 100644 --- a/front_end/src/types/projects.ts +++ b/front_end/src/types/projects.ts @@ -94,6 +94,7 @@ export type Tournament = TournamentPreview & { image_url: string; }; is_subscribed?: boolean; + follow_questions?: boolean; add_posts_to_main_feed: boolean; visibility: ProjectVisibility; default_permission?: ProjectPermissions | null; diff --git a/projects/services/subscriptions.py b/projects/services/subscriptions.py index 43c3b644eb..e9df2f55c4 100644 --- a/projects/services/subscriptions.py +++ b/projects/services/subscriptions.py @@ -1,4 +1,4 @@ -from django.db import IntegrityError, transaction +from django.db import transaction from django.db.models import Q, Case, When, Value, BooleanField from notifications.constants import MailingTags diff --git a/tests/unit/test_projects/test_services/test_follow_questions.py b/tests/unit/test_projects/test_services/test_follow_questions.py index 28b8424612..8e0508aa40 100644 --- a/tests/unit/test_projects/test_services/test_follow_questions.py +++ b/tests/unit/test_projects/test_services/test_follow_questions.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone as dt_timezone -from posts.models import Post, PostSubscription +from posts.models import PostSubscription from projects.models import ProjectSubscription from projects.permissions import ObjectPermission from projects.services.subscriptions import ( From 08e74287a563c2a24f169c09678dcb54693108e7 Mon Sep 17 00:00:00 2001 From: lsabor Date: Sun, 15 Mar 2026 14:26:29 -0700 Subject: [PATCH 3/5] formatting --- .../tournament/components/tournament_subscribe_modal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx index 7c3344b8a8..c8e3dba772 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx @@ -25,7 +25,9 @@ const TournamentSubscribeModal: FC = ({ isLoading, }) => { const t = useTranslations(); - const [followQuestions, setFollowQuestions] = useState(defaultFollowQuestions); + const [followQuestions, setFollowQuestions] = useState( + defaultFollowQuestions + ); const isFollow = mode === "follow"; From bef3229f120c37d3ba5e6d947001ff260ae4f9d6 Mon Sep 17 00:00:00 2001 From: lsabor Date: Sun, 15 Mar 2026 14:27:44 -0700 Subject: [PATCH 4/5] translations --- front_end/messages/cs.json | 11 +++++++++++ front_end/messages/es.json | 11 +++++++++++ front_end/messages/pt.json | 11 +++++++++++ front_end/messages/zh-TW.json | 11 +++++++++++ front_end/messages/zh.json | 11 +++++++++++ 5 files changed, 55 insertions(+) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 0e16c629cd..8bb471c66b 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2068,5 +2068,16 @@ "failedToLoadAggregation": "Nepodařilo se načíst agregaci", "questionFallbackLabel": "Otázka {id}", "openInAggregationExplorer": "Otevřít v Průzkumníku agregací", + "tournamentFollowModalTitle": "Sledovat turnaj", + "tournamentFollowModalDescription": "Obdržíte oznámení o nových otázkách a konečném pořadí.", + "tournamentFollowModalAlsoFollowQuestions": "Sledovat také všechny otázky", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Automaticky sledovat každou otázku s výchozím nastavením oznámení. Nově přidané otázky budou také sledovány.", + "tournamentFollowModalSubmit": "Sledovat", + "tournamentUnfollowModalTitle": "Zrušit sledování turnaje", + "tournamentUnfollowModalDescription": "Už nebudete dostávat oznámení o tomto turnaji.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Zrušit sledování všech otázek", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Odstraňte sledování všech otázek v tomto turnaji.", + "tournamentUnfollowModalSubmit": "Zrušit sledování", + "switchBackToSlidersHint": "přepněte se zpět na posuvníky pro hladké přizpůsobení", "thousandsOfOpenQuestions": "20 000+ otevřených otázek" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 080f516593..42b94f1a1e 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2068,5 +2068,16 @@ "failedToLoadAggregation": "No se pudo cargar la agregación", "questionFallbackLabel": "Pregunta {id}", "openInAggregationExplorer": "Abrir en el Explorador de Agregación", + "tournamentFollowModalTitle": "Seguir Torneo", + "tournamentFollowModalDescription": "Recibirás notificaciones sobre nuevas preguntas y clasificaciones finales.", + "tournamentFollowModalAlsoFollowQuestions": "Seguir también todas las preguntas", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Seguir automáticamente cada pregunta con la configuración de notificación predeterminada. Las preguntas recién añadidas también serán seguidas.", + "tournamentFollowModalSubmit": "Seguir", + "tournamentUnfollowModalTitle": "Dejar de Seguir Torneo", + "tournamentUnfollowModalDescription": "Ya no recibirás notificaciones sobre este torneo.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Dejar de seguir también todas las preguntas", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Elimina tus seguimientos de todas las preguntas en este torneo.", + "tournamentUnfollowModalSubmit": "Dejar de Seguir", + "switchBackToSlidersHint": "volver a los deslizadores para un ajuste suave", "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 70ff4203c6..2bd4b949f0 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2066,5 +2066,16 @@ "failedToLoadAggregation": "Falha ao carregar agregação", "questionFallbackLabel": "Pergunta {id}", "openInAggregationExplorer": "Abrir no Explorador de Agregações", + "tournamentFollowModalTitle": "Seguir Torneio", + "tournamentFollowModalDescription": "Você receberá notificações sobre novas perguntas e classificações finais.", + "tournamentFollowModalAlsoFollowQuestions": "Seguir todas as perguntas", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Siga automaticamente cada pergunta com as configurações padrão de notificação. Perguntas adicionadas recentemente também serão seguidas.", + "tournamentFollowModalSubmit": "Seguir", + "tournamentUnfollowModalTitle": "Deixar de Seguir Torneio", + "tournamentUnfollowModalDescription": "Você não receberá mais notificações sobre este torneio.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Deixar de seguir todas as perguntas", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Remova seus seguimentos de todas as perguntas neste torneio.", + "tournamentUnfollowModalSubmit": "Deixar de Seguir", + "switchBackToSlidersHint": "voltar para os controles deslizantes para um ajuste suave", "thousandsOfOpenQuestions": "20.000+ perguntas abertas" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index d72a7e1a65..4fca6248d0 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2065,5 +2065,16 @@ "failedToLoadAggregation": "載入聚合失敗", "questionFallbackLabel": "問題 {id}", "openInAggregationExplorer": "在聚合探索器中開啟", + "tournamentFollowModalTitle": "關注比賽", + "tournamentFollowModalDescription": "您將會收到關於新問題和最終排名的通知。", + "tournamentFollowModalAlsoFollowQuestions": "亦關注所有問題", + "tournamentFollowModalAlsoFollowQuestionsDescription": "自動關注每個問題,並使用默認通知設置。新增的問題也將被關注。", + "tournamentFollowModalSubmit": "關注", + "tournamentUnfollowModalTitle": "取消關注比賽", + "tournamentUnfollowModalDescription": "您將不再收到有關此比賽的通知。", + "tournamentUnfollowModalAlsoUnfollowQuestions": "亦取消關注所有問題", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "從此比賽中的所有問題中移除您的關注。", + "tournamentUnfollowModalSubmit": "取消關注", + "switchBackToSlidersHint": "切換回滑桿以獲得平滑的調整", "thousandsOfOpenQuestions": "20,000+ 開放問題" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 123d144abf..3d745bcb51 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2070,5 +2070,16 @@ "failedToLoadAggregation": "无法加载聚合", "questionFallbackLabel": "问题 {id}", "openInAggregationExplorer": "在聚合探索器中打开", + "tournamentFollowModalTitle": "关注比赛", + "tournamentFollowModalDescription": "您将收到关于新问题和最终排名的通知。", + "tournamentFollowModalAlsoFollowQuestions": "同时关注所有问题", + "tournamentFollowModalAlsoFollowQuestionsDescription": "根据默认通知设置自动关注每个问题。新添加的问题也会被关注。", + "tournamentFollowModalSubmit": "关注", + "tournamentUnfollowModalTitle": "取消关注比赛", + "tournamentUnfollowModalDescription": "您将不再收到关于此比赛的通知。", + "tournamentUnfollowModalAlsoUnfollowQuestions": "同时取消关注所有问题", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "取消您对此比赛中所有问题的关注。", + "tournamentUnfollowModalSubmit": "取消关注", + "switchBackToSlidersHint": "切换回滑块以获得平滑适配", "thousandsOfOpenQuestions": "20,000+ 开放问题" } From 10708d83e768ef126ed6c3dc13b8ebdbba5f6800 Mon Sep 17 00:00:00 2001 From: lsabor Date: Sat, 21 Mar 2026 08:00:43 -0700 Subject: [PATCH 5/5] address comments --- .../tournament/components/tournament_subscribe_button.tsx | 4 +--- .../tournament/components/tournament_subscribe_modal.tsx | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx index 3599990e1a..b8b9cfcfc9 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx @@ -111,9 +111,7 @@ const TournamentSubscribeButton: FC = ({ user, tournament }) => { isOpen={modalMode !== null} onClose={handleModalClose} mode={modalMode ?? "follow"} - defaultFollowQuestions={ - modalMode === "follow" ? followQuestions : followQuestions - } + defaultFollowQuestions={followQuestions} onSubmit={ modalMode === "follow" ? handleFollowSubmit : handleUnfollowSubmit } diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx index c8e3dba772..f7653327f9 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC, useState } from "react"; +import { FC, useEffect, useState } from "react"; import BaseModal from "@/components/base_modal"; import Button from "@/components/ui/button"; @@ -29,6 +29,10 @@ const TournamentSubscribeModal: FC = ({ defaultFollowQuestions ); + useEffect(() => { + setFollowQuestions(defaultFollowQuestions); + }, [defaultFollowQuestions]); + const isFollow = mode === "follow"; return (