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 61f5512c75..181cb26864 100644
--- a/pontoon/base/forms.py
+++ b/pontoon/base/forms.py
@@ -4,15 +4,16 @@
import bleach
+from notifications.signals import notify
+
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
-from django.utils import timezone
+from django.urls import reverse
from pontoon.base import utils
from pontoon.base.models import (
Locale,
- PermissionChangelog,
ProjectLocale,
User,
UserProfile,
@@ -91,6 +92,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):
"""
@@ -102,42 +105,11 @@ 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))
- after_count = self.user.badges_promotion_count
-
- # 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
-
- # Award Community Builder badge
- if (
- after_count > before_count
- and after_count in settings.BADGES_PROMOTION_THRESHOLDS
- ):
- # TODO: Send a notification to the user
- pass
-
class LocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm):
translators = forms.ModelMultipleChoiceField(
@@ -158,9 +130,45 @@ def save(self, *args, **kwargs):
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
+
self.assign_users_to_groups("translators", translators)
self.assign_users_to_groups("managers", managers)
+ after_count = self.user.badges_promotion_count
+
+ # Award Community Builder badge
+ if (
+ after_count > before_count
+ and after_count in settings.BADGES_PROMOTION_THRESHOLDS
+ ):
+ 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,
+ )
+
+ return self.community_builder_level
+
class ProjectLocalePermsForm(UserPermissionLogFormMixin, forms.ModelForm):
translators = forms.ModelMultipleChoiceField(
diff --git a/pontoon/base/models/user.py b/pontoon/base/models/user.py
index e839cb7bcc..2080d7b926 100644
--- a/pontoon/base/models/user.py
+++ b/pontoon/base/models/user.py
@@ -1,3 +1,4 @@
+from datetime import timedelta
from hashlib import md5
from urllib.parse import quote, urlencode
@@ -7,7 +8,7 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
-from django.db.models import Count, Q
+from django.db.models import Count, Exists, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
@@ -243,9 +244,27 @@ 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_entries = self.changed_permissions_log.filter(
action_type="added",
created_at__gte=settings.BADGES_START_DATE,
+ )
+
+ # 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 to simplify how promotions are retrieved. (see #2195)
+ return added_entries.exclude(
+ Exists(
+ self.changed_permissions_log.filter(
+ performed_by=OuterRef("performed_by"),
+ performed_on=OuterRef("performed_on"),
+ action_type="removed",
+ created_at__gt=OuterRef("created_at"),
+ created_at__lte=OuterRef("created_at") + timedelta(milliseconds=10),
+ )
+ )
).count()
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/settings/base.py b/pontoon/settings/base.py
index 2dbd3b2bf2..a8da0b2922 100644
--- a/pontoon/settings/base.py
+++ b/pontoon/settings/base.py
@@ -655,6 +655,7 @@ def _default_from_email():
"source_filenames": (
"js/lib/chart.umd.min.js",
"js/lib/chartjs-adapter-date-fns.bundle.min.js",
+ "js/lib/confetti.browser.js",
"js/table.js",
"js/progress-chart.js",
"js/double_list_selector.js",
@@ -789,10 +790,12 @@ def _default_from_email():
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
)
-STATICFILES_DIRS = [
+
+STATICFILES_DIRS = (
+ ("badges", "pontoon/contributors/static/img"),
os.path.join(TRANSLATE_DIR, "dist"),
os.path.join(TRANSLATE_DIR, "public"),
-]
+)
allowed_hosts = os.environ.get("ALLOWED_HOSTS")
ALLOWED_HOSTS = allowed_hosts.split(",") if allowed_hosts else []
@@ -936,7 +939,7 @@ def _default_from_email():
# Needed if site not hosted on HTTPS domains (like local setup)
if not (HEROKU_DEMO or SITE_URL.startswith("https")):
CSP_IMG_SRC = CSP_IMG_SRC + ("http://www.gravatar.com/avatar/",)
- CSP_WORKER_SRC = CSP_FRAME_SRC = CSP_FRAME_SRC + ("http:",)
+ CSP_WORKER_SRC = CSP_FRAME_SRC = CSP_FRAME_SRC + ("http:",) + ("blob:",)
# For absolute urls
try:
diff --git a/pontoon/teams/static/css/team.css b/pontoon/teams/static/css/team.css
index 6da190adef..e702e49a4e 100644
--- a/pontoon/teams/static/css/team.css
+++ b/pontoon/teams/static/css/team.css
@@ -72,6 +72,42 @@
display: none;
}
+.badge-tooltip {
+ background: var(--black-3);
+ border: 1px solid var(--dark-grey-1);
+ border-radius: 10px;
+ box-shadow: 0 0 20px -2px var(--tooltip-background);
+ box-sizing: border-box;
+ text-align: center;
+ margin: 0;
+ padding: 35px;
+ position: absolute;
+ display: none;
+ bottom: 350px;
+ left: 575px;
+ width: 375px;
+ z-index: 100;
+
+ img.badge {
+ border: 2px solid var(--main-border-1);
+ border-radius: 50%;
+ margin-bottom: 4px;
+ }
+
+ button {
+ position: absolute;
+ background: none;
+ border: none;
+ background: var(--button-background-1);
+ color: var(--light-grey-7);
+ padding: 2px 4px;
+ font-weight: 300;
+ border-radius: 3px;
+ top: 5px;
+ right: 5px;
+ }
+}
+
#permissions-form .selector-wrapper {
white-space: nowrap;
}
diff --git a/pontoon/teams/static/js/permissions.js b/pontoon/teams/static/js/permissions.js
index 23c63f515b..e99a3f89fc 100644
--- a/pontoon/teams/static/js/permissions.js
+++ b/pontoon/teams/static/js/permissions.js
@@ -50,8 +50,64 @@ $(function () {
url: $('#permissions-form').prop('action'),
type: $('#permissions-form').prop('method'),
data: $('#permissions-form').serialize(),
- success: function () {
+ success: function (response) {
Pontoon.endLoader('Permissions saved.');
+ const badgeLevel = $(response).find('#community-builder-level').val();
+
+ // Check for new badge notification
+ if (badgeLevel > 0) {
+ const $tooltip = $('#badge-tooltip-container');
+
+ if ($tooltip.length) {
+ $tooltip.show();
+
+ // Force a re-render of the text with the proper badge level
+ $tooltip
+ .find('p:nth-of-type(2)')
+ .text(
+ `Community Builder Badge level gained: Level ${badgeLevel}`,
+ );
+
+ $tooltip.find('button').one('click', function (e) {
+ e.preventDefault();
+ $tooltip.hide();
+ });
+ }
+
+ const duration = 2000;
+ const animationEnd = Date.now() + duration;
+ const defaults = {
+ startVelocity: 30,
+ spread: 360,
+ ticks: 60,
+ zIndex: 0,
+ };
+
+ function randomInRange(min, max) {
+ return Math.random() * (max - min) + min;
+ }
+
+ const interval = setInterval(function () {
+ const timeLeft = animationEnd - Date.now();
+
+ if (timeLeft <= 0) {
+ return clearInterval(interval);
+ }
+
+ const particleCount = 50 * (timeLeft / duration);
+ // since particles fall down, start a bit higher than random
+ confetti({
+ ...defaults,
+ particleCount,
+ origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
+ });
+ confetti({
+ ...defaults,
+ particleCount,
+ origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
+ });
+ }, 250);
+ }
},
error: function () {
Pontoon.endLoader('Oops, something went wrong.', 'error');
diff --git a/pontoon/teams/templates/teams/includes/permissions.html b/pontoon/teams/templates/teams/includes/permissions.html
index 185b581e83..83722307b4 100644
--- a/pontoon/teams/templates/teams/includes/permissions.html
+++ b/pontoon/teams/templates/teams/includes/permissions.html
@@ -3,6 +3,17 @@