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 @@
+ +
+

New badge level gained!

+

+

+ +

You can view your new badge on your profile page.

+ +
+ +

General (default team permissions for all projects)

diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py index f69c0ff9c6..413c97421b 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..59d7480791 100644 --- a/translate/public/locale/en-US/translate.ftl +++ b/translate/public/locale/en-US/translate.ftl @@ -215,6 +215,14 @@ editor-NewContributorTooltip--team-info = Check the team information befo editor-NewContributorTooltip--team-managers = Reach out to team managers if you have questions or want to learn more about contributing. +## Editor Badge Tooltip +## Popup notification when users gain a new badge level + +editor-BadgeTooltip--intro = New badge level gained! +editor-BadgeTooltip--info = { $badgeName } Badge level gained: Level { $badgeLevel } +editor-BadgeTooltip--profile = You can view your new badge on your profile page. + + ## Editor Unsaved Changes ## Renders the unsaved changes popup diff --git a/translate/public/translate.html b/translate/public/translate.html index ff3fae5a85..31b0d60eda 100644 --- a/translate/public/translate.html +++ b/translate/public/translate.html @@ -11,6 +11,8 @@ + + Pontoon diff --git a/translate/src/App.tsx b/translate/src/App.tsx index 1a99a86286..377507883d 100644 --- a/translate/src/App.tsx +++ b/translate/src/App.tsx @@ -10,6 +10,7 @@ import { initLocale, Locale, updateLocale } from './context/Locale'; import { Location } from './context/Location'; import { MentionUsersProvider } from './context/MentionUsers'; import { NotificationProvider } from './context/Notification'; +import { BadgeTooltipProvider } from './context/BadgeNotification'; import { ThemeProvider } from './context/Theme'; import { WaveLoader } from './modules/loaders'; @@ -30,6 +31,7 @@ import { Navigation } from './modules/navbar/components/Navigation'; import { ProjectInfo } from './modules/projectinfo/components/ProjectInfo'; import { ResourceProgress } from './modules/resourceprogress'; import { SearchBox } from './modules/search/components/SearchBox'; +import { BadgeTooltip } from './modules/notification/components/BadgeTooltip'; /** * Main entry point to the application. Will render the structure of the page. @@ -64,41 +66,44 @@ export function App() { return ( - - - -
- -
- - - {allProjects ? null : } - - -
-
-
- - -
-
- {batchactions.entities.length === 0 ? ( - - ) : ( - + + + + +
+ +
+ + + {allProjects ? null : } + + +
+
+
+ + +
+
+ + {batchactions.entities.length === 0 ? ( + + ) : ( + + )} +
-
- -
-
-
-
+ +
+ + + + ); 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/BadgeNotification.tsx b/translate/src/context/BadgeNotification.tsx new file mode 100644 index 0000000000..367ecc7ae0 --- /dev/null +++ b/translate/src/context/BadgeNotification.tsx @@ -0,0 +1,31 @@ +import { createContext, useEffect, useState } from 'react'; +import { Localized } from '@fluent/react'; + +export type BadgeTooltipMessage = Readonly<{ + badgeName: string | null; + badgeLevel: number | null; +}>; + +export const BadgeTooltipMessage = createContext( + null, +); + +export const ShowBadgeTooltip = createContext< + (tooltip: BadgeTooltipMessage | null) => void +>(() => {}); + +export function BadgeTooltipProvider({ + children, +}: { + children: React.ReactElement; +}) { + const [message, setMessage] = useState(null); + + return ( + + setMessage(tooltip)}> + {children} + + + ); +} 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..0438940556 100644 --- a/translate/src/modules/editor/hooks/useSendTranslation.ts +++ b/translate/src/modules/editor/hooks/useSendTranslation.ts @@ -23,6 +23,7 @@ import { updateResource } from '~/modules/resource/actions'; import { updateStats } from '~/modules/stats/actions'; import { useAppDispatch, useAppSelector } from '~/hooks'; import { serializeEntry, getPlainMessage } from '~/utils/message'; +import { ShowBadgeTooltip } from '~/context/BadgeNotification'; /** * Return a function to send a translation to the server. @@ -33,6 +34,7 @@ export function useSendTranslation(): (ignoreWarnings?: boolean) => void { const location = useContext(Location); const locale = useContext(Locale); const showNotification = useContext(ShowNotification); + const showBadgeTooltip = useContext(ShowBadgeTooltip); const forceSuggestions = useAppSelector( (state) => state.user.settings.forceSuggestions, ); @@ -84,6 +86,14 @@ export function useSendTranslation(): (ignoreWarnings?: boolean) => void { updateEntityTranslation(entity.pk, pluralForm, content.translation), ); + const badgeLevel = content.badge_update?.level; + if (badgeLevel) { + showBadgeTooltip({ + badgeName: 'Translation Champion', + badgeLevel: 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..b2a7974c7c 100644 --- a/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts +++ b/translate/src/modules/editor/hooks/useUpdateTranslationStatus.ts @@ -8,6 +8,7 @@ import { FailedChecksData } from '~/context/FailedChecksData'; import { HistoryData } from '~/context/HistoryData'; import { Location } from '~/context/Location'; import { ShowNotification } from '~/context/Notification'; +import { ShowBadgeTooltip } from '~/context/BadgeNotification'; import { updateEntityTranslation } from '~/modules/entities/actions'; import { usePushNextTranslatable } from '~/modules/entities/hooks'; import { @@ -38,6 +39,7 @@ export function useUpdateTranslationStatus( const { resource } = useContext(Location); const showNotification = useContext(ShowNotification); + const showBadgeTooltip = useContext(ShowBadgeTooltip); const { entity, hasPluralForms, pluralForm } = useContext(EntityView); const pushNextTranslatable = usePushNextTranslatable(); const { updateHistory } = useContext(HistoryData); @@ -89,6 +91,15 @@ 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) { + showBadgeTooltip({ + badgeName: 'Review Master', + badgeLevel: badgeLevel, + }); + } + // Update stats in the resource menu. dispatch( updateResource( diff --git a/translate/src/modules/notification/components/BadgeTooltip.css b/translate/src/modules/notification/components/BadgeTooltip.css new file mode 100644 index 0000000000..e30118b630 --- /dev/null +++ b/translate/src/modules/notification/components/BadgeTooltip.css @@ -0,0 +1,37 @@ +.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; + top: 300px; + left: 200px; + width: 375px; + z-index: 100; + + &.showing { + top: 0; + } + + 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; + } + + img.badge { + border: 2px solid var(--main-border-1); + border-radius: 50%; + } +} diff --git a/translate/src/modules/notification/components/BadgeTooltip.tsx b/translate/src/modules/notification/components/BadgeTooltip.tsx new file mode 100644 index 0000000000..f5692ddc68 --- /dev/null +++ b/translate/src/modules/notification/components/BadgeTooltip.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import React, { useCallback, useContext } from 'react'; +import { + BadgeTooltipMessage, + ShowBadgeTooltip, +} from '~/context/BadgeNotification'; +import { USER } from '~/modules/user'; +import { useAppSelector } from '~/hooks'; +import { Localized } from '@fluent/react'; +import Fireworks from 'react-canvas-confetti/dist/presets/fireworks'; + +import './BadgeTooltip.css'; + +export function BadgeTooltip(): React.ReactElement<'div'> { + const user = useAppSelector((state) => state.user.username); + const tooltip = useContext(BadgeTooltipMessage); + const showBadgeTooltip = useContext(ShowBadgeTooltip); + const hide = useCallback(() => { + showBadgeTooltip(null); + }, [showBadgeTooltip]); + + if (!tooltip) return <>; + + const { badgeName, badgeLevel } = tooltip; + const className = classNames('badge-tooltip', tooltip && 'showing'); + + let imagePath; + switch (badgeName) { + case 'Review Master': + imagePath = '/static/badges/review_master_badge.svg'; + break; + case 'Translation Champion': + imagePath = '/static/badges/translation_champion_badge.svg'; + break; + default: + imagePath = ''; + } + + return ( + <> + +
+ + + +

New badge level gained!

+
+ + +

+ {badgeName} Badge level gained: Level {badgeLevel} +

+
+ + + + }} + > +

{'You can view your new badge on your profile page.'}

+
+
+ + ); +} 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..1f80caa8ce 100644 --- a/translate/src/modules/notification/components/NotificationPanel.tsx +++ b/translate/src/modules/notification/components/NotificationPanel.tsx @@ -32,8 +32,10 @@ export function NotificationPanel(): React.ReactElement<'div'> { const className = classNames('notification-panel', message && 'showing'); return ( -
- {message?.content} -
+ <> +
+ {message?.content} +
+ ); }