From 8462f347377ad0c21b86ef4af28996417a1fcc9c Mon Sep 17 00:00:00 2001 From: Harmit Goswami Date: Fri, 15 Nov 2024 11:13:43 -0800 Subject: [PATCH 1/3] Init commit for badge notifications In this state, there is still an issue with differentiating between demotions and promotions for the Community Builder Badge, and awarding badges accordingly --- eslint.config.mjs | 1 + package-lock.json | 30 ++++ pontoon/base/forms.py | 33 ++++- .../static/js/lib/confetti.browser.min.js | 8 ++ pontoon/settings/base.py | 3 +- pontoon/teams/static/js/permissions.js | 43 +++++- .../templates/teams/includes/permissions.html | 4 + pontoon/teams/views.py | 4 +- pontoon/translations/views.py | 135 ++++++++++++++---- translate/package.json | 1 + translate/public/locale/en-US/translate.ftl | 2 + translate/src/api/translation.ts | 19 ++- translate/src/context/Notification.tsx | 8 +- .../editor/hooks/useSendTranslation.ts | 6 + .../hooks/useUpdateTranslationStatus.ts | 7 + .../components/NotificationPanel.css | 3 +- .../components/NotificationPanel.tsx | 12 +- .../src/modules/notification/messages.tsx | 32 +++++ 18 files changed, 308 insertions(+), 43 deletions(-) create mode 100644 pontoon/base/static/js/lib/confetti.browser.min.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 0c5e5f7831..292320fad1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -59,6 +59,7 @@ export default [ jQuery: 'readonly', Clipboard: 'readonly', Chart: 'readonly', + confetti: 'readonly', NProgress: 'readonly', diff_match_patch: 'readonly', Highcharts: 'readonly', diff --git a/package-lock.json b/package-lock.json index f32b61aa48..ea280f91c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3751,6 +3751,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.6.4.tgz", + "integrity": "sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==", + "license": "MIT" + }, "node_modules/@types/cheerio": { "version": "0.22.31", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.31.tgz", @@ -5111,6 +5117,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz", + "integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -11298,6 +11314,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-canvas-confetti": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/react-canvas-confetti/-/react-canvas-confetti-2.0.7.tgz", + "integrity": "sha512-DIj44O35TPAwJkUSIZqWdVsgAMHtVf8h7YNmnr3jF3bn5mG+d7Rh9gEcRmdJfYgRzh6K+MAGujwUoIqQyLnMJw==", + "license": "MIT", + "dependencies": { + "@types/canvas-confetti": "^1.6.4", + "canvas-confetti": "^1.9.2" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-clientside-effect": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", @@ -13362,6 +13391,7 @@ "messageformat": "^4.0.0-2", "nprogress": "^0.2.0", "react": "^16.14.0", + "react-canvas-confetti": "^2.0.7", "react-dom": "^16.14.0", "react-infinite-scroll-hook": "^4.0.1", "react-linkify": "^0.2.2", diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 5bd8d16d2f..d31404a468 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -2,8 +2,11 @@ import bleach +from notifications.signals import notify + from django import forms from django.conf import settings +from django.urls import reverse from django.utils import timezone from pontoon.base import utils @@ -88,6 +91,8 @@ class UserPermissionLogFormMixin: def __init__(self, *args, **kwargs): self.user = kwargs.pop("user") super().__init__(*args, **kwargs) + # Track if user reached new level for Community Builder Badge + self.community_builder_level = 0 def assign_users_to_groups(self, group_name, users): """ @@ -132,8 +137,30 @@ def assign_users_to_groups(self, group_name, users): after_count > before_count and after_count in settings.BADGES_PROMOTION_THRESHOLDS ): - # TODO: Send a notification to the user - pass + self.community_builder_level = ( + settings.BADGES_PROMOTION_THRESHOLDS.index(after_count) + 1 + ) + desc = """ + You have gained a new badge level! +
+ Community Builder Badge: Level {level} +
+ You can view this badge on your profile page . + """.format( + level=self.community_builder_level, + profile_href=reverse( + "pontoon.contributors.contributor.username", + kwargs={ + "username": self.user.username, + }, + ), + ) + notify.send( + sender=self.user, + recipient=self.user, + verb="", # Triggers render of description only + description=desc, + ) class LocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm): @@ -158,6 +185,8 @@ def save(self, *args, **kwargs): self.assign_users_to_groups("translators", translators) self.assign_users_to_groups("managers", managers) + return self.community_builder_level + class ProjectLocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm): translators = forms.ModelMultipleChoiceField( diff --git a/pontoon/base/static/js/lib/confetti.browser.min.js b/pontoon/base/static/js/lib/confetti.browser.min.js new file mode 100644 index 0000000000..a3b518afe3 --- /dev/null +++ b/pontoon/base/static/js/lib/confetti.browser.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.19.2. + * Original file: /npm/canvas-confetti@1.9.3/dist/confetti.browser.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(t,e){!function t(e,a,n,r){var o=!!(e.Worker&&e.Blob&&e.Promise&&e.OffscreenCanvas&&e.OffscreenCanvasRenderingContext2D&&e.HTMLCanvasElement&&e.HTMLCanvasElement.prototype.transferControlToOffscreen&&e.URL&&e.URL.createObjectURL),i="function"==typeof Path2D&&"function"==typeof DOMMatrix,l=function(){if(!e.OffscreenCanvas)return!1;var t=new OffscreenCanvas(1,1),a=t.getContext("2d");a.fillRect(0,0,1,1);var n=t.transferToImageBitmap();try{a.createPattern(n,"no-repeat")}catch(t){return!1}return!0}();function s(){}function c(t){var n=a.exports.Promise,r=void 0!==n?n:e.Promise;return"function"==typeof r?new r(t):(t(s,s),null)}var h,f,u,d,m,g,p,b,M,v,y,w=(h=l,f=new Map,{transform:function(t){if(h)return t;if(f.has(t))return f.get(t);var e=new OffscreenCanvas(t.width,t.height);return e.getContext("2d").drawImage(t,0,0),f.set(t,e),e},clear:function(){f.clear()}}),x=(m=Math.floor(1e3/60),g={},p=0,"function"==typeof requestAnimationFrame&&"function"==typeof cancelAnimationFrame?(u=function(t){var e=Math.random();return g[e]=requestAnimationFrame((function a(n){p===n||p+m-1 + {% if community_builder_badge %} + + {% endif %} +

General (default team permissions for all projects)

diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py index 5b1a2fd967..659f2551b9 100644 --- a/pontoon/teams/views.py +++ b/pontoon/teams/views.py @@ -168,6 +168,7 @@ def ajax_permissions(request, locale): locale = get_object_or_404(Locale, code=locale) project_locales = locale.project_locale.visible().visible_for(request.user) + community_builder_level = 0 if request.method == "POST": locale_form = forms.LocalePermsForm( request.POST, instance=locale, prefix="general", user=request.user @@ -180,7 +181,7 @@ def ajax_permissions(request, locale): ) if locale_form.is_valid() and project_locale_form.is_valid(): - locale_form.save() + community_builder_level = locale_form.save() project_locale_form.save() else: @@ -249,6 +250,7 @@ def ajax_permissions(request, locale): "project_locale_form": project_locale_form, "project_locales": project_locales, "hide_project_selector": hide_project_selector, + "community_builder_badge": community_builder_level, }, ) diff --git a/pontoon/translations/views.py b/pontoon/translations/views.py index d717145806..35c1d30080 100644 --- a/pontoon/translations/views.py +++ b/pontoon/translations/views.py @@ -170,18 +170,43 @@ def create_translation(request): description=desc, ) - # Award Translation Champion Badge stats - if user.badges_translation_count in settings.BADGES_TRANSLATION_THRESHOLDS: - # TODO: Send a notification to the user - pass - - return JsonResponse( - { - "status": True, - "translation": translation.serialize(), - "stats": TranslatedResource.objects.stats(project, paths, locale), + # Send Translation Champion Badge notification information + response_data = { + "status": True, + "translation": translation.serialize(), + "stats": TranslatedResource.objects.stats(project, paths, locale), + } + + translation_count = user.badges_translation_count + if translation_count in settings.BADGES_TRANSLATION_THRESHOLDS: + response_data["badge_update"] = { + "name": "Translation Champion Badge", + "level": settings.BADGES_TRANSLATION_THRESHOLDS.index(translation_count) + + 1, } - ) + desc = """ + You have gained a new badge level! +
+ Translation Champion Badge: Level {level} +
+ You can view this badge on your profile page . + """.format( + level=settings.BADGES_TRANSLATION_THRESHOLDS.index(translation_count) + 1, + profile_href=reverse( + "pontoon.contributors.contributor.username", + kwargs={ + "username": user.username, + }, + ), + ) + notify.send( + sender=user, + recipient=user, + verb="", # Triggers render of description only + description=desc, + ) + + return JsonResponse(response_data) @utils.require_AJAX @@ -315,17 +340,41 @@ def approve_translation(request): plural_form=translation.plural_form, ) - # Reward Review Master Badge stats - if user.badges_review_count in settings.BADGES_TRANSLATION_THRESHOLDS: - # TODO: Send a notification to the user - pass - - return JsonResponse( - { - "translation": active_translation.serialize(), - "stats": TranslatedResource.objects.stats(project, paths, locale), + # Send Review Master Badge notification information + response_data = { + "translation": active_translation.serialize(), + "stats": TranslatedResource.objects.stats(project, paths, locale), + } + + review_count = user.badges_review_count + if review_count in settings.BADGES_TRANSLATION_THRESHOLDS: + response_data["badge_update"] = { + "name": "Review Master Badge", + "level": settings.BADGES_TRANSLATION_THRESHOLDS.index(review_count) + 1, } - ) + desc = """ + You have gained a new badge level! +
+ Review Master Badge: Level {level} +
+ You can view this badge on your profile page . + """.format( + level=settings.BADGES_TRANSLATION_THRESHOLDS.index(review_count) + 1, + profile_href=reverse( + "pontoon.contributors.contributor.username", + kwargs={ + "username": user.username, + }, + ), + ) + notify.send( + sender=user, + recipient=user, + verb="", # Triggers render of description only + description=desc, + ) + + return JsonResponse(response_data) @utils.require_AJAX @@ -450,17 +499,41 @@ def reject_translation(request): plural_form=translation.plural_form, ) - # Reward Review Master Badge stats - if request.user.badges_review_count in settings.BADGES_TRANSLATION_THRESHOLDS: - # TODO: Send a notification to the user - pass - - return JsonResponse( - { - "translation": active_translation.serialize(), - "stats": TranslatedResource.objects.stats(project, paths, locale), + # Send Review Master Badge notification information + response_data = { + "translation": active_translation.serialize(), + "stats": TranslatedResource.objects.stats(project, paths, locale), + } + + review_count = request.user.badges_review_count + if review_count in settings.BADGES_TRANSLATION_THRESHOLDS: + response_data["badge_update"] = { + "name": "Review Master Badge", + "level": settings.BADGES_TRANSLATION_THRESHOLDS.index(review_count) + 1, } - ) + desc = """ + You have gained a new badge level! +
+ Review Master Badge: Level {level} +
+ You can view this badge on your profile page . + """.format( + level=settings.BADGES_TRANSLATION_THRESHOLDS.index(review_count) + 1, + profile_href=reverse( + "pontoon.contributors.contributor.username", + kwargs={ + "username": request.user.username, + }, + ), + ) + notify.send( + sender=request.user, + recipient=request.user, + verb="", # Triggers render of description only + description=desc, + ) + + return JsonResponse(response_data) @utils.require_AJAX diff --git a/translate/package.json b/translate/package.json index 7c142a7158..035c94ec5b 100644 --- a/translate/package.json +++ b/translate/package.json @@ -28,6 +28,7 @@ "messageformat": "^4.0.0-2", "nprogress": "^0.2.0", "react": "^16.14.0", + "react-canvas-confetti": "^2.0.7", "react-dom": "^16.14.0", "react-infinite-scroll-hook": "^4.0.1", "react-linkify": "^0.2.2", diff --git a/translate/public/locale/en-US/translate.ftl b/translate/public/locale/en-US/translate.ftl index 98e2088be2..563d85b397 100644 --- a/translate/public/locale/en-US/translate.ftl +++ b/translate/public/locale/en-US/translate.ftl @@ -577,6 +577,8 @@ notification--ftl-not-supported-rich-editor = Translation not supported in rich notification--entity-not-found = Can’t load specified string notification--string-link-copied = Link copied to clipboard notification--comment-added = Comment added +notification--translation-champion-badge = Translation Champion Badge level gained: Level { $badgeLevel } +notification--review-master-badge = Review Master Badge level gained: Level { $badgeLevel } ## OtherLocales Translation diff --git a/translate/src/api/translation.ts b/translate/src/api/translation.ts index 9dc56de83c..39d7980400 100644 --- a/translate/src/api/translation.ts +++ b/translate/src/api/translation.ts @@ -57,10 +57,20 @@ export type APIStats = { total: number; }; +export type BadgeInfo = { + name: string; + level: number; +}; + type CreateTranslationResponse = | { status: false; same: true; failedChecks?: never } | { status: false; failedChecks: ApiFailedChecks; same?: never } - | { status: true; translation: EntityTranslation; stats: APIStats }; + | { + status: true; + translation: EntityTranslation; + badge_update?: BadgeInfo; + stats: APIStats; + }; /** * Create a new translation. @@ -110,7 +120,12 @@ export function createTranslation( type SetTranslationResponse = | { failedChecks: ApiFailedChecks; string: string } // indicates failed approve - | { translation: EntityTranslation; stats: APIStats; failedChecks?: never }; + | { + translation: EntityTranslation; + stats: APIStats; + badge_update?: BadgeInfo; + failedChecks?: never; + }; export function setTranslationStatus( change: ChangeOperation, diff --git a/translate/src/context/Notification.tsx b/translate/src/context/Notification.tsx index d515841573..1c1cd259cb 100644 --- a/translate/src/context/Notification.tsx +++ b/translate/src/context/Notification.tsx @@ -1,6 +1,12 @@ import { createContext, useEffect, useState } from 'react'; -type NotificationType = 'debug' | 'error' | 'info' | 'success' | 'warning'; +type NotificationType = + | 'debug' + | 'error' + | 'info' + | 'success' + | 'warning' + | 'badge'; export type NotificationMessage = Readonly<{ type: NotificationType; diff --git a/translate/src/modules/editor/hooks/useSendTranslation.ts b/translate/src/modules/editor/hooks/useSendTranslation.ts index c0dc95ee43..fdb1f92d3d 100644 --- a/translate/src/modules/editor/hooks/useSendTranslation.ts +++ b/translate/src/modules/editor/hooks/useSendTranslation.ts @@ -18,6 +18,7 @@ import { usePushNextTranslatable } from '~/modules/entities/hooks'; import { SAME_TRANSLATION, TRANSLATION_SAVED, + TRANSLATION_CHAMPION_BADGE, } from '~/modules/notification/messages'; import { updateResource } from '~/modules/resource/actions'; import { updateStats } from '~/modules/stats/actions'; @@ -84,6 +85,11 @@ export function useSendTranslation(): (ignoreWarnings?: boolean) => void { updateEntityTranslation(entity.pk, pluralForm, content.translation), ); + const badgeLevel = content.badge_update?.level; + if (badgeLevel) { + showNotification(TRANSLATION_CHAMPION_BADGE(badgeLevel)); + } + // Update stats in the filter panel and resource menu if possible. if (content.stats) { dispatch(updateStats(content.stats)); diff --git a/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts b/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts index ff3bd1f1a2..c188c642e2 100644 --- a/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts +++ b/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts @@ -19,6 +19,7 @@ import { UNABLE_TO_REJECT_TRANSLATION, UNABLE_TO_UNAPPROVE_TRANSLATION, UNABLE_TO_UNREJECT_TRANSLATION, + REVIEW_MASTER_BADGE, } from '~/modules/notification/messages'; import { updateResource } from '~/modules/resource/actions'; import { updateStats } from '~/modules/stats/actions'; @@ -89,6 +90,12 @@ export function useUpdateTranslationStatus( // Update stats in the progress chart and the filter panel. dispatch(updateStats(results.stats)); + // Check for update in badge level + const badgeLevel = results.badge_update?.level; + if (badgeLevel) { + showNotification(REVIEW_MASTER_BADGE(badgeLevel)); + } + // Update stats in the resource menu. dispatch( updateResource( diff --git a/translate/src/modules/notification/components/NotificationPanel.css b/translate/src/modules/notification/components/NotificationPanel.css index 728df27b43..ea226c71b7 100644 --- a/translate/src/modules/notification/components/NotificationPanel.css +++ b/translate/src/modules/notification/components/NotificationPanel.css @@ -16,7 +16,8 @@ } .notification-panel .info, -.notification-panel .success { +.notification-panel .success, +.notification-panel .badge { color: var(--status-translated); } diff --git a/translate/src/modules/notification/components/NotificationPanel.tsx b/translate/src/modules/notification/components/NotificationPanel.tsx index 81fb0f79ae..bfff771ed2 100644 --- a/translate/src/modules/notification/components/NotificationPanel.tsx +++ b/translate/src/modules/notification/components/NotificationPanel.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; import React, { useCallback, useContext, useEffect, useRef } from 'react'; +import Fireworks from 'react-canvas-confetti/dist/presets/fireworks'; import { NotificationMessage, ShowNotification } from '~/context/Notification'; @@ -32,8 +33,13 @@ export function NotificationPanel(): React.ReactElement<'div'> { const className = classNames('notification-panel', message && 'showing'); return ( -
- {message?.content} -
+ <> +
+ {message?.content} +
+ {message?.type == 'badge' && ( + + )} + ); } diff --git a/translate/src/modules/notification/messages.tsx b/translate/src/modules/notification/messages.tsx index cbdcf6e24a..8054e20d31 100644 --- a/translate/src/modules/notification/messages.tsx +++ b/translate/src/modules/notification/messages.tsx @@ -180,3 +180,35 @@ export const COMMENT_ADDED: NotificationMessage = { ), type: 'info', }; + +export const TRANSLATION_CHAMPION_BADGE = ( + badgeLevel: number, +): NotificationMessage => { + return { + content: ( + + {'Translation Champion Badge level gained: Level {badgeLevel}'} + + ), + type: 'badge', + }; +}; + +export const REVIEW_MASTER_BADGE = ( + badgeLevel: number, +): NotificationMessage => { + return { + content: ( + + {'Review Master Badge level gained: Level {badgeLevel}'} + + ), + type: 'badge', + }; +}; From 8a16a6568601c40551b64922fc0e310194f99885 Mon Sep 17 00:00:00 2001 From: Harmit Goswami Date: Fri, 15 Nov 2024 11:42:34 -0800 Subject: [PATCH 2/3] Fixed linting error from previous merge --- pontoon/base/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 13bfa197cd..27e1aaa5d1 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -8,8 +8,8 @@ from django import forms from django.conf import settings -from django.urls import reverse from django.core.exceptions import ValidationError +from django.urls import reverse from django.utils import timezone from pontoon.base import utils From 24a35191273e71aea0a0fb1c60820fa29225eb76 Mon Sep 17 00:00:00 2001 From: Harmit Goswami Date: Wed, 20 Nov 2024 18:51:25 -0500 Subject: [PATCH 3/3] Fixed confetti file Changed the confetti file to be the unminified version instead. This commit also has a broken implementation of the permissions count bug, where I attempted to self-correct permissions counts inside of user.py --- pontoon/base/forms.py | 75 +- pontoon/base/models/user.py | 20 +- .../base/static/js/lib/confetti.browser.js | 888 ++++++++++++++++++ .../static/js/lib/confetti.browser.min.js | 8 - pontoon/settings/base.py | 2 +- 5 files changed, 944 insertions(+), 49 deletions(-) create mode 100644 pontoon/base/static/js/lib/confetti.browser.js delete mode 100644 pontoon/base/static/js/lib/confetti.browser.min.js diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 27e1aaa5d1..73fe2a93b8 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -107,33 +107,55 @@ def assign_users_to_groups(self, group_name, users): group.user_set.clear() - before_count = self.user.badges_promotion_count - now = timezone.now() - if users: group.user_set.add(*users) log_group_members(self.user, group, (add_users, remove_users)) + +class LocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm): + translators = forms.ModelMultipleChoiceField( + queryset=User.objects.all(), required=False + ) + managers = forms.ModelMultipleChoiceField( + queryset=User.objects.all(), required=False + ) + + class Meta: + model = Locale + fields = ("translators", "managers") + + def save(self, *args, **kwargs): + """ + Locale perms logs + """ + translators = self.cleaned_data.get("translators", User.objects.none()) + managers = self.cleaned_data.get("managers", User.objects.none()) + + before_count = self.user.badges_promotion_count + + now = timezone.now() + self.assign_users_to_groups("translators", translators) + removal = PermissionChangelog.objects.filter( + performed_by=self.user, + action_type=PermissionChangelog.ActionType.REMOVED, + created_at__gte=now, + ) + self.assign_users_to_groups("managers", managers) + after_count = self.user.badges_promotion_count + # Check if user was demoted from Manager to Translator + # In this case, it doesn't count as a promotion + # # TODO: # This code is the only consumer of the PermissionChangelog # model, so we should refactor in the future to simplify # how promotions are retrieved. (see #2195) - # Check if user was demoted from Manager to Translator - # In this case, it doesn't count as a promotion - if group_name == "managers": - removal = PermissionChangelog.objects.filter( - performed_by=self.user, - action_type=PermissionChangelog.ActionType.REMOVED, - created_at__gte=now, - ) - if removal: - for item in removal: - if "managers" in item.group.name: - after_count -= 1 + for item in removal: + if "managers" in item.group.name: + after_count -= 1 # Award Community Builder badge if ( @@ -165,29 +187,6 @@ def assign_users_to_groups(self, group_name, users): description=desc, ) - -class LocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm): - translators = forms.ModelMultipleChoiceField( - queryset=User.objects.all(), required=False - ) - managers = forms.ModelMultipleChoiceField( - queryset=User.objects.all(), required=False - ) - - class Meta: - model = Locale - fields = ("translators", "managers") - - def save(self, *args, **kwargs): - """ - Locale perms logs - """ - translators = self.cleaned_data.get("translators", User.objects.none()) - managers = self.cleaned_data.get("managers", User.objects.none()) - - self.assign_users_to_groups("translators", translators) - self.assign_users_to_groups("managers", managers) - return self.community_builder_level diff --git a/pontoon/base/models/user.py b/pontoon/base/models/user.py index e839cb7bcc..366f394d71 100644 --- a/pontoon/base/models/user.py +++ b/pontoon/base/models/user.py @@ -243,10 +243,26 @@ def badges_review_count(self): @property def badges_promotion_count(self): """Role promotions performed by user that count towards their badges""" - return self.changed_permissions_log.filter( + added_logs = self.changed_permissions_log.filter( action_type="added", created_at__gte=settings.BADGES_START_DATE, - ).count() + ).order_by("created_at") + + unmatched_added = [] + + # Don't count any 'added' logs that have a corresponding + # 'removed' log within the same group + for added in added_logs: + removed_exists = self.changed_permissions_log.filter( + action_type="removed", + created_at__gte=settings.BADGES_START_DATE, + group=added.group, + ).exists() + + if not removed_exists: + unmatched_added.append(added) + + return len(unmatched_added) @property diff --git a/pontoon/base/static/js/lib/confetti.browser.js b/pontoon/base/static/js/lib/confetti.browser.js new file mode 100644 index 0000000000..a54a48e4ef --- /dev/null +++ b/pontoon/base/static/js/lib/confetti.browser.js @@ -0,0 +1,888 @@ +// canvas-confetti v1.9.3 built on 2024-04-30T22:19:17.794Z +!(function (window, module) { + // source content + /* globals Map */ + + (function main(global, module, isWorker, workerSize) { + var canUseWorker = !!( + global.Worker && + global.Blob && + global.Promise && + global.OffscreenCanvas && + global.OffscreenCanvasRenderingContext2D && + global.HTMLCanvasElement && + global.HTMLCanvasElement.prototype.transferControlToOffscreen && + global.URL && + global.URL.createObjectURL); + + var canUsePaths = typeof Path2D === 'function' && typeof DOMMatrix === 'function'; + var canDrawBitmap = (function () { + // this mostly supports ssr + if (!global.OffscreenCanvas) { + return false; + } + + var canvas = new OffscreenCanvas(1, 1); + var ctx = canvas.getContext('2d'); + ctx.fillRect(0, 0, 1, 1); + var bitmap = canvas.transferToImageBitmap(); + + try { + ctx.createPattern(bitmap, 'no-repeat'); + } catch (e) { + return false; + } + + return true; + })(); + + function noop() {} + + // create a promise if it exists, otherwise, just + // call the function directly + function promise(func) { + var ModulePromise = module.exports.Promise; + var Prom = ModulePromise !== void 0 ? ModulePromise : global.Promise; + + if (typeof Prom === 'function') { + return new Prom(func); + } + + func(noop, noop); + + return null; + } + + var bitmapMapper = (function (skipTransform, map) { + // see https://github.com/catdad/canvas-confetti/issues/209 + // creating canvases is actually pretty expensive, so we should create a + // 1:1 map for bitmap:canvas, so that we can animate the confetti in + // a performant manner, but also not store them forever so that we don't + // have a memory leak + return { + transform: function(bitmap) { + if (skipTransform) { + return bitmap; + } + + if (map.has(bitmap)) { + return map.get(bitmap); + } + + var canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + var ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0); + + map.set(bitmap, canvas); + + return canvas; + }, + clear: function () { + map.clear(); + } + }; + })(canDrawBitmap, new Map()); + + var raf = (function () { + var TIME = Math.floor(1000 / 60); + var frame, cancel; + var frames = {}; + var lastFrameTime = 0; + + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + frame = function (cb) { + var id = Math.random(); + + frames[id] = requestAnimationFrame(function onFrame(time) { + if (lastFrameTime === time || lastFrameTime + TIME - 1 < time) { + lastFrameTime = time; + delete frames[id]; + + cb(); + } else { + frames[id] = requestAnimationFrame(onFrame); + } + }); + + return id; + }; + cancel = function (id) { + if (frames[id]) { + cancelAnimationFrame(frames[id]); + } + }; + } else { + frame = function (cb) { + return setTimeout(cb, TIME); + }; + cancel = function (timer) { + return clearTimeout(timer); + }; + } + + return { frame: frame, cancel: cancel }; + }()); + + var getWorker = (function () { + var worker; + var prom; + var resolves = {}; + + function decorate(worker) { + function execute(options, callback) { + worker.postMessage({ options: options || {}, callback: callback }); + } + worker.init = function initWorker(canvas) { + var offscreen = canvas.transferControlToOffscreen(); + worker.postMessage({ canvas: offscreen }, [offscreen]); + }; + + worker.fire = function fireWorker(options, size, done) { + if (prom) { + execute(options, null); + return prom; + } + + var id = Math.random().toString(36).slice(2); + + prom = promise(function (resolve) { + function workerDone(msg) { + if (msg.data.callback !== id) { + return; + } + + delete resolves[id]; + worker.removeEventListener('message', workerDone); + + prom = null; + + bitmapMapper.clear(); + + done(); + resolve(); + } + + worker.addEventListener('message', workerDone); + execute(options, id); + + resolves[id] = workerDone.bind(null, { data: { callback: id }}); + }); + + return prom; + }; + + worker.reset = function resetWorker() { + worker.postMessage({ reset: true }); + + for (var id in resolves) { + resolves[id](); + delete resolves[id]; + } + }; + } + + return function () { + if (worker) { + return worker; + } + + if (!isWorker && canUseWorker) { + var code = [ + 'var CONFETTI, SIZE = {}, module = {};', + '(' + main.toString() + ')(this, module, true, SIZE);', + 'onmessage = function(msg) {', + ' if (msg.data.options) {', + ' CONFETTI(msg.data.options).then(function () {', + ' if (msg.data.callback) {', + ' postMessage({ callback: msg.data.callback });', + ' }', + ' });', + ' } else if (msg.data.reset) {', + ' CONFETTI && CONFETTI.reset();', + ' } else if (msg.data.resize) {', + ' SIZE.width = msg.data.resize.width;', + ' SIZE.height = msg.data.resize.height;', + ' } else if (msg.data.canvas) {', + ' SIZE.width = msg.data.canvas.width;', + ' SIZE.height = msg.data.canvas.height;', + ' CONFETTI = module.exports.create(msg.data.canvas);', + ' }', + '}', + ].join('\n'); + try { + worker = new Worker(URL.createObjectURL(new Blob([code]))); + } catch (e) { + // eslint-disable-next-line no-console + typeof console !== undefined && typeof console.warn === 'function' ? console.warn('🎊 Could not load worker', e) : null; + + return null; + } + + decorate(worker); + } + + return worker; + }; + })(); + + var defaults = { + particleCount: 50, + angle: 90, + spread: 45, + startVelocity: 45, + decay: 0.9, + gravity: 1, + drift: 0, + ticks: 200, + x: 0.5, + y: 0.5, + shapes: ['square', 'circle'], + zIndex: 100, + colors: [ + '#26ccff', + '#a25afd', + '#ff5e7e', + '#88ff5a', + '#fcff42', + '#ffa62d', + '#ff36ff' + ], + // probably should be true, but back-compat + disableForReducedMotion: false, + scalar: 1 + }; + + function convert(val, transform) { + return transform ? transform(val) : val; + } + + function isOk(val) { + return !(val === null || val === undefined); + } + + function prop(options, name, transform) { + return convert( + options && isOk(options[name]) ? options[name] : defaults[name], + transform + ); + } + + function onlyPositiveInt(number){ + return number < 0 ? 0 : Math.floor(number); + } + + function randomInt(min, max) { + // [min, max) + return Math.floor(Math.random() * (max - min)) + min; + } + + function toDecimal(str) { + return parseInt(str, 16); + } + + function colorsToRgb(colors) { + return colors.map(hexToRgb); + } + + function hexToRgb(str) { + var val = String(str).replace(/[^0-9a-f]/gi, ''); + + if (val.length < 6) { + val = val[0]+val[0]+val[1]+val[1]+val[2]+val[2]; + } + + return { + r: toDecimal(val.substring(0,2)), + g: toDecimal(val.substring(2,4)), + b: toDecimal(val.substring(4,6)) + }; + } + + function getOrigin(options) { + var origin = prop(options, 'origin', Object); + origin.x = prop(origin, 'x', Number); + origin.y = prop(origin, 'y', Number); + + return origin; + } + + function setCanvasWindowSize(canvas) { + canvas.width = document.documentElement.clientWidth; + canvas.height = document.documentElement.clientHeight; + } + + function setCanvasRectSize(canvas) { + var rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + } + + function getCanvas(zIndex) { + var canvas = document.createElement('canvas'); + + canvas.style.position = 'fixed'; + canvas.style.top = '0px'; + canvas.style.left = '0px'; + canvas.style.pointerEvents = 'none'; + canvas.style.zIndex = zIndex; + + return canvas; + } + + function ellipse(context, x, y, radiusX, radiusY, rotation, startAngle, endAngle, antiClockwise) { + context.save(); + context.translate(x, y); + context.rotate(rotation); + context.scale(radiusX, radiusY); + context.arc(0, 0, 1, startAngle, endAngle, antiClockwise); + context.restore(); + } + + function randomPhysics(opts) { + var radAngle = opts.angle * (Math.PI / 180); + var radSpread = opts.spread * (Math.PI / 180); + + return { + x: opts.x, + y: opts.y, + wobble: Math.random() * 10, + wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05), + velocity: (opts.startVelocity * 0.5) + (Math.random() * opts.startVelocity), + angle2D: -radAngle + ((0.5 * radSpread) - (Math.random() * radSpread)), + tiltAngle: (Math.random() * (0.75 - 0.25) + 0.25) * Math.PI, + color: opts.color, + shape: opts.shape, + tick: 0, + totalTicks: opts.ticks, + decay: opts.decay, + drift: opts.drift, + random: Math.random() + 2, + tiltSin: 0, + tiltCos: 0, + wobbleX: 0, + wobbleY: 0, + gravity: opts.gravity * 3, + ovalScalar: 0.6, + scalar: opts.scalar, + flat: opts.flat + }; + } + + function updateFetti(context, fetti) { + fetti.x += Math.cos(fetti.angle2D) * fetti.velocity + fetti.drift; + fetti.y += Math.sin(fetti.angle2D) * fetti.velocity + fetti.gravity; + fetti.velocity *= fetti.decay; + + if (fetti.flat) { + fetti.wobble = 0; + fetti.wobbleX = fetti.x + (10 * fetti.scalar); + fetti.wobbleY = fetti.y + (10 * fetti.scalar); + + fetti.tiltSin = 0; + fetti.tiltCos = 0; + fetti.random = 1; + } else { + fetti.wobble += fetti.wobbleSpeed; + fetti.wobbleX = fetti.x + ((10 * fetti.scalar) * Math.cos(fetti.wobble)); + fetti.wobbleY = fetti.y + ((10 * fetti.scalar) * Math.sin(fetti.wobble)); + + fetti.tiltAngle += 0.1; + fetti.tiltSin = Math.sin(fetti.tiltAngle); + fetti.tiltCos = Math.cos(fetti.tiltAngle); + fetti.random = Math.random() + 2; + } + + var progress = (fetti.tick++) / fetti.totalTicks; + + var x1 = fetti.x + (fetti.random * fetti.tiltCos); + var y1 = fetti.y + (fetti.random * fetti.tiltSin); + var x2 = fetti.wobbleX + (fetti.random * fetti.tiltCos); + var y2 = fetti.wobbleY + (fetti.random * fetti.tiltSin); + + context.fillStyle = 'rgba(' + fetti.color.r + ', ' + fetti.color.g + ', ' + fetti.color.b + ', ' + (1 - progress) + ')'; + + context.beginPath(); + + if (canUsePaths && fetti.shape.type === 'path' && typeof fetti.shape.path === 'string' && Array.isArray(fetti.shape.matrix)) { + context.fill(transformPath2D( + fetti.shape.path, + fetti.shape.matrix, + fetti.x, + fetti.y, + Math.abs(x2 - x1) * 0.1, + Math.abs(y2 - y1) * 0.1, + Math.PI / 10 * fetti.wobble + )); + } else if (fetti.shape.type === 'bitmap') { + var rotation = Math.PI / 10 * fetti.wobble; + var scaleX = Math.abs(x2 - x1) * 0.1; + var scaleY = Math.abs(y2 - y1) * 0.1; + var width = fetti.shape.bitmap.width * fetti.scalar; + var height = fetti.shape.bitmap.height * fetti.scalar; + + var matrix = new DOMMatrix([ + Math.cos(rotation) * scaleX, + Math.sin(rotation) * scaleX, + -Math.sin(rotation) * scaleY, + Math.cos(rotation) * scaleY, + fetti.x, + fetti.y + ]); + + // apply the transform matrix from the confetti shape + matrix.multiplySelf(new DOMMatrix(fetti.shape.matrix)); + + var pattern = context.createPattern(bitmapMapper.transform(fetti.shape.bitmap), 'no-repeat'); + pattern.setTransform(matrix); + + context.globalAlpha = (1 - progress); + context.fillStyle = pattern; + context.fillRect( + fetti.x - (width / 2), + fetti.y - (height / 2), + width, + height + ); + context.globalAlpha = 1; + } else if (fetti.shape === 'circle') { + context.ellipse ? + context.ellipse(fetti.x, fetti.y, Math.abs(x2 - x1) * fetti.ovalScalar, Math.abs(y2 - y1) * fetti.ovalScalar, Math.PI / 10 * fetti.wobble, 0, 2 * Math.PI) : + ellipse(context, fetti.x, fetti.y, Math.abs(x2 - x1) * fetti.ovalScalar, Math.abs(y2 - y1) * fetti.ovalScalar, Math.PI / 10 * fetti.wobble, 0, 2 * Math.PI); + } else if (fetti.shape === 'star') { + var rot = Math.PI / 2 * 3; + var innerRadius = 4 * fetti.scalar; + var outerRadius = 8 * fetti.scalar; + var x = fetti.x; + var y = fetti.y; + var spikes = 5; + var step = Math.PI / spikes; + + while (spikes--) { + x = fetti.x + Math.cos(rot) * outerRadius; + y = fetti.y + Math.sin(rot) * outerRadius; + context.lineTo(x, y); + rot += step; + + x = fetti.x + Math.cos(rot) * innerRadius; + y = fetti.y + Math.sin(rot) * innerRadius; + context.lineTo(x, y); + rot += step; + } + } else { + context.moveTo(Math.floor(fetti.x), Math.floor(fetti.y)); + context.lineTo(Math.floor(fetti.wobbleX), Math.floor(y1)); + context.lineTo(Math.floor(x2), Math.floor(y2)); + context.lineTo(Math.floor(x1), Math.floor(fetti.wobbleY)); + } + + context.closePath(); + context.fill(); + + return fetti.tick < fetti.totalTicks; + } + + function animate(canvas, fettis, resizer, size, done) { + var animatingFettis = fettis.slice(); + var context = canvas.getContext('2d'); + var animationFrame; + var destroy; + + var prom = promise(function (resolve) { + function onDone() { + animationFrame = destroy = null; + + context.clearRect(0, 0, size.width, size.height); + bitmapMapper.clear(); + + done(); + resolve(); + } + + function update() { + if (isWorker && !(size.width === workerSize.width && size.height === workerSize.height)) { + size.width = canvas.width = workerSize.width; + size.height = canvas.height = workerSize.height; + } + + if (!size.width && !size.height) { + resizer(canvas); + size.width = canvas.width; + size.height = canvas.height; + } + + context.clearRect(0, 0, size.width, size.height); + + animatingFettis = animatingFettis.filter(function (fetti) { + return updateFetti(context, fetti); + }); + + if (animatingFettis.length) { + animationFrame = raf.frame(update); + } else { + onDone(); + } + } + + animationFrame = raf.frame(update); + destroy = onDone; + }); + + return { + addFettis: function (fettis) { + animatingFettis = animatingFettis.concat(fettis); + + return prom; + }, + canvas: canvas, + promise: prom, + reset: function () { + if (animationFrame) { + raf.cancel(animationFrame); + } + + if (destroy) { + destroy(); + } + } + }; + } + + function confettiCannon(canvas, globalOpts) { + var isLibCanvas = !canvas; + var allowResize = !!prop(globalOpts || {}, 'resize'); + var hasResizeEventRegistered = false; + var globalDisableForReducedMotion = prop(globalOpts, 'disableForReducedMotion', Boolean); + var shouldUseWorker = canUseWorker && !!prop(globalOpts || {}, 'useWorker'); + var worker = shouldUseWorker ? getWorker() : null; + var resizer = isLibCanvas ? setCanvasWindowSize : setCanvasRectSize; + var initialized = (canvas && worker) ? !!canvas.__confetti_initialized : false; + var preferLessMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion)').matches; + var animationObj; + + function fireLocal(options, size, done) { + var particleCount = prop(options, 'particleCount', onlyPositiveInt); + var angle = prop(options, 'angle', Number); + var spread = prop(options, 'spread', Number); + var startVelocity = prop(options, 'startVelocity', Number); + var decay = prop(options, 'decay', Number); + var gravity = prop(options, 'gravity', Number); + var drift = prop(options, 'drift', Number); + var colors = prop(options, 'colors', colorsToRgb); + var ticks = prop(options, 'ticks', Number); + var shapes = prop(options, 'shapes'); + var scalar = prop(options, 'scalar'); + var flat = !!prop(options, 'flat'); + var origin = getOrigin(options); + + var temp = particleCount; + var fettis = []; + + var startX = canvas.width * origin.x; + var startY = canvas.height * origin.y; + + while (temp--) { + fettis.push( + randomPhysics({ + x: startX, + y: startY, + angle: angle, + spread: spread, + startVelocity: startVelocity, + color: colors[temp % colors.length], + shape: shapes[randomInt(0, shapes.length)], + ticks: ticks, + decay: decay, + gravity: gravity, + drift: drift, + scalar: scalar, + flat: flat + }) + ); + } + + // if we have a previous canvas already animating, + // add to it + if (animationObj) { + return animationObj.addFettis(fettis); + } + + animationObj = animate(canvas, fettis, resizer, size , done); + + return animationObj.promise; + } + + function fire(options) { + var disableForReducedMotion = globalDisableForReducedMotion || prop(options, 'disableForReducedMotion', Boolean); + var zIndex = prop(options, 'zIndex', Number); + + if (disableForReducedMotion && preferLessMotion) { + return promise(function (resolve) { + resolve(); + }); + } + + if (isLibCanvas && animationObj) { + // use existing canvas from in-progress animation + canvas = animationObj.canvas; + } else if (isLibCanvas && !canvas) { + // create and initialize a new canvas + canvas = getCanvas(zIndex); + document.body.appendChild(canvas); + } + + if (allowResize && !initialized) { + // initialize the size of a user-supplied canvas + resizer(canvas); + } + + var size = { + width: canvas.width, + height: canvas.height + }; + + if (worker && !initialized) { + worker.init(canvas); + } + + initialized = true; + + if (worker) { + canvas.__confetti_initialized = true; + } + + function onResize() { + if (worker) { + // TODO this really shouldn't be immediate, because it is expensive + var obj = { + getBoundingClientRect: function () { + if (!isLibCanvas) { + return canvas.getBoundingClientRect(); + } + } + }; + + resizer(obj); + + worker.postMessage({ + resize: { + width: obj.width, + height: obj.height + } + }); + return; + } + + // don't actually query the size here, since this + // can execute frequently and rapidly + size.width = size.height = null; + } + + function done() { + animationObj = null; + + if (allowResize) { + hasResizeEventRegistered = false; + global.removeEventListener('resize', onResize); + } + + if (isLibCanvas && canvas) { + if (document.body.contains(canvas)) { + document.body.removeChild(canvas); + } + canvas = null; + initialized = false; + } + } + + if (allowResize && !hasResizeEventRegistered) { + hasResizeEventRegistered = true; + global.addEventListener('resize', onResize, false); + } + + if (worker) { + return worker.fire(options, size, done); + } + + return fireLocal(options, size, done); + } + + fire.reset = function () { + if (worker) { + worker.reset(); + } + + if (animationObj) { + animationObj.reset(); + } + }; + + return fire; + } + + // Make default export lazy to defer worker creation until called. + var defaultFire; + function getDefaultFire() { + if (!defaultFire) { + defaultFire = confettiCannon(null, { useWorker: true, resize: true }); + } + return defaultFire; + } + + function transformPath2D(pathString, pathMatrix, x, y, scaleX, scaleY, rotation) { + var path2d = new Path2D(pathString); + + var t1 = new Path2D(); + t1.addPath(path2d, new DOMMatrix(pathMatrix)); + + var t2 = new Path2D(); + // see https://developer.mozilla.org/en-US/docs/Web/API/DOMMatrix/DOMMatrix + t2.addPath(t1, new DOMMatrix([ + Math.cos(rotation) * scaleX, + Math.sin(rotation) * scaleX, + -Math.sin(rotation) * scaleY, + Math.cos(rotation) * scaleY, + x, + y + ])); + + return t2; + } + + function shapeFromPath(pathData) { + if (!canUsePaths) { + throw new Error('path confetti are not supported in this browser'); + } + + var path, matrix; + + if (typeof pathData === 'string') { + path = pathData; + } else { + path = pathData.path; + matrix = pathData.matrix; + } + + var path2d = new Path2D(path); + var tempCanvas = document.createElement('canvas'); + var tempCtx = tempCanvas.getContext('2d'); + + if (!matrix) { + // attempt to figure out the width of the path, up to 1000x1000 + var maxSize = 1000; + var minX = maxSize; + var minY = maxSize; + var maxX = 0; + var maxY = 0; + var width, height; + + // do some line skipping... this is faster than checking + // every pixel and will be mostly still correct + for (var x = 0; x < maxSize; x += 2) { + for (var y = 0; y < maxSize; y += 2) { + if (tempCtx.isPointInPath(path2d, x, y, 'nonzero')) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + width = maxX - minX; + height = maxY - minY; + + var maxDesiredSize = 10; + var scale = Math.min(maxDesiredSize/width, maxDesiredSize/height); + + matrix = [ + scale, 0, 0, scale, + -Math.round((width/2) + minX) * scale, + -Math.round((height/2) + minY) * scale + ]; + } + + return { + type: 'path', + path: path, + matrix: matrix + }; + } + + function shapeFromText(textData) { + var text, + scalar = 1, + color = '#000000', + // see https://nolanlawson.com/2022/04/08/the-struggle-of-using-native-emoji-on-the-web/ + fontFamily = '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", "Twemoji Mozilla", "system emoji", sans-serif'; + + if (typeof textData === 'string') { + text = textData; + } else { + text = textData.text; + scalar = 'scalar' in textData ? textData.scalar : scalar; + fontFamily = 'fontFamily' in textData ? textData.fontFamily : fontFamily; + color = 'color' in textData ? textData.color : color; + } + + // all other confetti are 10 pixels, + // so this pixel size is the de-facto 100% scale confetti + var fontSize = 10 * scalar; + var font = '' + fontSize + 'px ' + fontFamily; + + var canvas = new OffscreenCanvas(fontSize, fontSize); + var ctx = canvas.getContext('2d'); + + ctx.font = font; + var size = ctx.measureText(text); + var width = Math.ceil(size.actualBoundingBoxRight + size.actualBoundingBoxLeft); + var height = Math.ceil(size.actualBoundingBoxAscent + size.actualBoundingBoxDescent); + + var padding = 2; + var x = size.actualBoundingBoxLeft + padding; + var y = size.actualBoundingBoxAscent + padding; + width += padding + padding; + height += padding + padding; + + canvas = new OffscreenCanvas(width, height); + ctx = canvas.getContext('2d'); + ctx.font = font; + ctx.fillStyle = color; + + ctx.fillText(text, x, y); + + var scale = 1 / scalar; + + return { + type: 'bitmap', + // TODO these probably need to be transfered for workers + bitmap: canvas.transferToImageBitmap(), + matrix: [scale, 0, 0, scale, -width * scale / 2, -height * scale / 2] + }; + } + + module.exports = function() { + return getDefaultFire().apply(this, arguments); + }; + module.exports.reset = function() { + getDefaultFire().reset(); + }; + module.exports.create = confettiCannon; + module.exports.shapeFromPath = shapeFromPath; + module.exports.shapeFromText = shapeFromText; + }((function () { + if (typeof window !== 'undefined') { + return window; + } + + if (typeof self !== 'undefined') { + return self; + } + + return this || {}; + })(), module, false)); + + // end source content + + window.confetti = module.exports; + }(window, {})); + \ No newline at end of file diff --git a/pontoon/base/static/js/lib/confetti.browser.min.js b/pontoon/base/static/js/lib/confetti.browser.min.js deleted file mode 100644 index a3b518afe3..0000000000 --- a/pontoon/base/static/js/lib/confetti.browser.min.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Minified by jsDelivr using Terser v5.19.2. - * Original file: /npm/canvas-confetti@1.9.3/dist/confetti.browser.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -!function(t,e){!function t(e,a,n,r){var o=!!(e.Worker&&e.Blob&&e.Promise&&e.OffscreenCanvas&&e.OffscreenCanvasRenderingContext2D&&e.HTMLCanvasElement&&e.HTMLCanvasElement.prototype.transferControlToOffscreen&&e.URL&&e.URL.createObjectURL),i="function"==typeof Path2D&&"function"==typeof DOMMatrix,l=function(){if(!e.OffscreenCanvas)return!1;var t=new OffscreenCanvas(1,1),a=t.getContext("2d");a.fillRect(0,0,1,1);var n=t.transferToImageBitmap();try{a.createPattern(n,"no-repeat")}catch(t){return!1}return!0}();function s(){}function c(t){var n=a.exports.Promise,r=void 0!==n?n:e.Promise;return"function"==typeof r?new r(t):(t(s,s),null)}var h,f,u,d,m,g,p,b,M,v,y,w=(h=l,f=new Map,{transform:function(t){if(h)return t;if(f.has(t))return f.get(t);var e=new OffscreenCanvas(t.width,t.height);return e.getContext("2d").drawImage(t,0,0),f.set(t,e),e},clear:function(){f.clear()}}),x=(m=Math.floor(1e3/60),g={},p=0,"function"==typeof requestAnimationFrame&&"function"==typeof cancelAnimationFrame?(u=function(t){var e=Math.random();return g[e]=requestAnimationFrame((function a(n){p===n||p+m-1