mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/
mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts
And a couple of gitignore tweaks
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Defines the interface of a canvas based room effect
|
||||
*/
|
||||
export default interface ICanvasEffect {
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas The canvas instance as the render target of the animation
|
||||
* @param {number} timeout? A timeout that defines the runtime of the animation (defaults to false)
|
||||
*/
|
||||
start: (canvas: HTMLCanvasElement, timeout?: number) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Stops the current animation
|
||||
*/
|
||||
stop: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns a value that defines if the animation is currently running
|
||||
*/
|
||||
isRunning: boolean;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import type ICanvasEffect from "../ICanvasEffect";
|
||||
|
||||
export type ConfettiOptions = {
|
||||
/**
|
||||
* max confetti count
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* particle animation speed
|
||||
*/
|
||||
speed: number;
|
||||
/**
|
||||
* the confetti animation frame interval in milliseconds
|
||||
*/
|
||||
frameInterval: number;
|
||||
/**
|
||||
* the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible)
|
||||
*/
|
||||
alpha: number;
|
||||
/**
|
||||
* use gradient instead of solid particle color
|
||||
*/
|
||||
gradient: boolean;
|
||||
};
|
||||
|
||||
type ConfettiParticle = {
|
||||
color: string;
|
||||
color2: string;
|
||||
x: number;
|
||||
y: number;
|
||||
diameter: number;
|
||||
tilt: number;
|
||||
tiltAngleIncrement: number;
|
||||
tiltAngle: number;
|
||||
};
|
||||
|
||||
export const DefaultOptions: ConfettiOptions = {
|
||||
maxCount: 150,
|
||||
speed: 3,
|
||||
frameInterval: 15,
|
||||
alpha: 1.0,
|
||||
gradient: false,
|
||||
};
|
||||
|
||||
export default class Confetti implements ICanvasEffect {
|
||||
private readonly options: ConfettiOptions;
|
||||
|
||||
public constructor(options: { [key: string]: any }) {
|
||||
this.options = { ...DefaultOptions, ...options };
|
||||
}
|
||||
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private supportsAnimationFrame = window.requestAnimationFrame;
|
||||
private colors = [
|
||||
"rgba(30,144,255,",
|
||||
"rgba(107,142,35,",
|
||||
"rgba(255,215,0,",
|
||||
"rgba(255,192,203,",
|
||||
"rgba(106,90,205,",
|
||||
"rgba(173,216,230,",
|
||||
"rgba(238,130,238,",
|
||||
"rgba(152,251,152,",
|
||||
"rgba(70,130,180,",
|
||||
"rgba(244,164,96,",
|
||||
"rgba(210,105,30,",
|
||||
"rgba(220,20,60,",
|
||||
];
|
||||
|
||||
private lastFrameTime = Date.now();
|
||||
private particles: Array<ConfettiParticle> = [];
|
||||
private waveAngle = 0;
|
||||
|
||||
public isRunning = false;
|
||||
|
||||
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise<void> => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.context = canvas.getContext("2d");
|
||||
this.particles = [];
|
||||
const count = this.options.maxCount;
|
||||
while (this.particles.length < count) {
|
||||
this.particles.push(this.resetParticle({} as ConfettiParticle, canvas.width, canvas.height));
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.runAnimation();
|
||||
if (timeout) {
|
||||
window.setTimeout(this.stop, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
public stop = async (): Promise<void> => {
|
||||
this.isRunning = false;
|
||||
};
|
||||
|
||||
private resetParticle = (particle: ConfettiParticle, width: number, height: number): ConfettiParticle => {
|
||||
particle.color = this.colors[(Math.random() * this.colors.length) | 0] + (this.options.alpha + ")");
|
||||
if (this.options.gradient) {
|
||||
particle.color2 = this.colors[(Math.random() * this.colors.length) | 0] + (this.options.alpha + ")");
|
||||
} else {
|
||||
particle.color2 = particle.color;
|
||||
}
|
||||
particle.x = Math.random() * width;
|
||||
particle.y = Math.random() * -height;
|
||||
particle.diameter = Math.random() * 10 + 5;
|
||||
particle.tilt = Math.random() * -10;
|
||||
particle.tiltAngleIncrement = Math.random() * 0.07 + 0.05;
|
||||
particle.tiltAngle = Math.random() * Math.PI;
|
||||
return particle;
|
||||
};
|
||||
|
||||
private runAnimation = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.particles.length === 0) {
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const delta = now - this.lastFrameTime;
|
||||
if (!this.supportsAnimationFrame || delta > this.options.frameInterval) {
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
this.updateParticles();
|
||||
this.drawParticles(this.context);
|
||||
this.lastFrameTime = now - (delta % this.options.frameInterval);
|
||||
}
|
||||
requestAnimationFrame(this.runAnimation);
|
||||
}
|
||||
};
|
||||
|
||||
private drawParticles = (context: CanvasRenderingContext2D): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
let x;
|
||||
let x2;
|
||||
let y2;
|
||||
for (const particle of this.particles) {
|
||||
this.context.beginPath();
|
||||
context.lineWidth = particle.diameter;
|
||||
x2 = particle.x + particle.tilt;
|
||||
x = x2 + particle.diameter / 2;
|
||||
y2 = particle.y + particle.tilt + particle.diameter / 2;
|
||||
if (this.options.gradient) {
|
||||
const gradient = context.createLinearGradient(x, particle.y, x2, y2);
|
||||
gradient.addColorStop(0, particle.color);
|
||||
gradient.addColorStop(1.0, particle.color2);
|
||||
context.strokeStyle = gradient;
|
||||
} else {
|
||||
context.strokeStyle = particle.color;
|
||||
}
|
||||
context.moveTo(x, particle.y);
|
||||
context.lineTo(x2, y2);
|
||||
context.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
private updateParticles = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
const width = this.context.canvas.width;
|
||||
const height = this.context.canvas.height;
|
||||
let particle: ConfettiParticle;
|
||||
this.waveAngle += 0.01;
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
particle = this.particles[i];
|
||||
if (!this.isRunning && particle.y < -15) {
|
||||
particle.y = height + 100;
|
||||
} else {
|
||||
particle.tiltAngle += particle.tiltAngleIncrement;
|
||||
particle.x += Math.sin(this.waveAngle) - 0.5;
|
||||
particle.y += (Math.cos(this.waveAngle) + particle.diameter + this.options.speed) * 0.5;
|
||||
particle.tilt = Math.sin(particle.tiltAngle) * 15;
|
||||
}
|
||||
if (particle.x > width + 20 || particle.x < -20 || particle.y > height) {
|
||||
if (this.isRunning && this.particles.length <= this.options.maxCount) {
|
||||
this.resetParticle(particle, width, height);
|
||||
} else {
|
||||
this.particles.splice(i, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export type Effect<TOptions extends { [key: string]: any }> = {
|
||||
/**
|
||||
* one or more emojis that will trigger this effect
|
||||
*/
|
||||
emojis: Array<string>;
|
||||
/**
|
||||
* the matrix message type that will trigger this effect
|
||||
*/
|
||||
msgType: string;
|
||||
/**
|
||||
* the room command to trigger this effect
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* a function that returns the translatable description of the effect
|
||||
*/
|
||||
description: () => TranslationKey;
|
||||
/**
|
||||
* a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message
|
||||
*/
|
||||
fallbackMessage: () => string;
|
||||
/**
|
||||
* animation options
|
||||
*/
|
||||
options: TOptions;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type ICanvasEffect from "../ICanvasEffect";
|
||||
|
||||
export type FireworksOptions = {
|
||||
/**
|
||||
* max fireworks count
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* gravity value that firework adds to shift from it's start position
|
||||
*/
|
||||
gravity: number;
|
||||
};
|
||||
|
||||
type FireworksParticle = {
|
||||
/**
|
||||
* color
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* x,y are the point where the particle starts to position on canvas
|
||||
*/
|
||||
x: number;
|
||||
y: number;
|
||||
/**
|
||||
* vx,vy shift values from x and y
|
||||
*/
|
||||
vx: number;
|
||||
vy: number;
|
||||
/**
|
||||
* the alpha opacity of the firework particle (between 0 and 1, where 1 is opaque and 0 is invisible)
|
||||
*/
|
||||
alpha: number;
|
||||
/**
|
||||
* w,h width and height
|
||||
*/
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
export const DefaultOptions: FireworksOptions = {
|
||||
maxCount: 500,
|
||||
gravity: 0.05,
|
||||
};
|
||||
|
||||
export default class Fireworks implements ICanvasEffect {
|
||||
private readonly options: FireworksOptions;
|
||||
|
||||
public constructor(options: { [key: string]: any }) {
|
||||
this.options = { ...DefaultOptions, ...options };
|
||||
}
|
||||
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private supportsAnimationFrame = window.requestAnimationFrame;
|
||||
private particles: Array<FireworksParticle> = [];
|
||||
public isRunning = false;
|
||||
|
||||
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise<void> => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.context = canvas.getContext("2d");
|
||||
this.supportsAnimationFrame.call(window, this.updateWorld);
|
||||
if (timeout) {
|
||||
window.setTimeout(this.stop, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
private updateWorld = (): void => {
|
||||
if (!this.isRunning && this.particles.length === 0) return;
|
||||
this.update();
|
||||
this.paint();
|
||||
this.supportsAnimationFrame.call(window, this.updateWorld);
|
||||
};
|
||||
|
||||
private update = (): void => {
|
||||
if (this.particles.length < this.options.maxCount && this.isRunning) {
|
||||
this.createFirework();
|
||||
}
|
||||
const alive: FireworksParticle[] = [];
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
if (this.move(this.particles[i])) {
|
||||
alive.push(this.particles[i]);
|
||||
}
|
||||
}
|
||||
this.particles = alive;
|
||||
};
|
||||
|
||||
private paint = (): void => {
|
||||
if (!this.context || !this.context.canvas) return;
|
||||
this.context.globalCompositeOperation = "destination-out";
|
||||
this.context.fillStyle = "rgba(0,0,0,0.5)";
|
||||
this.context.fillRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
this.context.globalCompositeOperation = "lighter";
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
this.drawParticle(this.particles[i]);
|
||||
}
|
||||
};
|
||||
|
||||
private createFirework = (): void => {
|
||||
if (!this.context || !this.context.canvas) return;
|
||||
const width = this.context.canvas.width;
|
||||
const height = this.context.canvas.height;
|
||||
const xPoint = Math.random() * (width - 200) + 100;
|
||||
const yPoint = Math.random() * (height - 200) + 100;
|
||||
const nFire = Math.random() * 50 + 100;
|
||||
const color =
|
||||
"rgb(" +
|
||||
~~(Math.random() * 200 + 55) +
|
||||
"," +
|
||||
~~(Math.random() * 200 + 55) +
|
||||
"," +
|
||||
~~(Math.random() * 200 + 55) +
|
||||
")";
|
||||
for (let i = 0; i < nFire; i++) {
|
||||
const particle = <FireworksParticle>{};
|
||||
particle.color = color;
|
||||
particle.w = particle.h = Math.random() * 4 + 1;
|
||||
particle.x = xPoint - particle.w / 2;
|
||||
particle.y = yPoint - particle.h / 2;
|
||||
particle.vx = (Math.random() - 0.5) * 10;
|
||||
particle.vy = (Math.random() - 0.5) * 10;
|
||||
particle.alpha = Math.random() * 0.5 + 0.5;
|
||||
const vy = Math.sqrt(25 - particle.vx * particle.vx);
|
||||
if (Math.abs(particle.vy) > vy) {
|
||||
particle.vy = particle.vy > 0 ? vy : -vy;
|
||||
}
|
||||
this.particles.push(particle);
|
||||
}
|
||||
};
|
||||
|
||||
public stop = async (): Promise<void> => {
|
||||
this.isRunning = false;
|
||||
};
|
||||
|
||||
private drawParticle = (particle: FireworksParticle): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
this.context.save();
|
||||
this.context.beginPath();
|
||||
|
||||
this.context.translate(particle.x + particle.w / 2, particle.y + particle.h / 2);
|
||||
this.context.arc(0, 0, particle.w, 0, Math.PI * 2);
|
||||
this.context.fillStyle = particle.color;
|
||||
this.context.globalAlpha = particle.alpha;
|
||||
|
||||
this.context.closePath();
|
||||
this.context.fill();
|
||||
this.context.restore();
|
||||
};
|
||||
|
||||
private move = (particle: FireworksParticle): boolean => {
|
||||
particle.x += particle.vx;
|
||||
particle.vy += this.options.gravity;
|
||||
particle.y += particle.vy;
|
||||
particle.alpha -= 0.01;
|
||||
return !(
|
||||
particle.x <= -particle.w ||
|
||||
particle.x >= screen.width ||
|
||||
particle.y >= screen.height ||
|
||||
particle.alpha <= 0
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022 Arseny Uskov
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import type ICanvasEffect from "../ICanvasEffect";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
|
||||
export type HeartOptions = {
|
||||
/**
|
||||
* The maximum number of hearts to render at a given time
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* The amount of gravity to apply to the hearts
|
||||
*/
|
||||
gravity: number;
|
||||
/**
|
||||
* The maximum amount of drift (horizontal sway) to apply to the hearts. Each heart varies.
|
||||
*/
|
||||
maxDrift: number;
|
||||
/**
|
||||
* The maximum amount of tilt to apply to the heart. Each heart varies.
|
||||
*/
|
||||
maxRot: number;
|
||||
};
|
||||
|
||||
type Heart = {
|
||||
x: number;
|
||||
y: number;
|
||||
xCol: number;
|
||||
scale: number;
|
||||
maximumDrift: number;
|
||||
maximumRot: number;
|
||||
gravity: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const DefaultOptions: HeartOptions = {
|
||||
maxCount: 120,
|
||||
gravity: 3.2,
|
||||
maxDrift: 5,
|
||||
maxRot: 5,
|
||||
};
|
||||
|
||||
const KEY_FRAME_INTERVAL = 15; // 15ms, roughly
|
||||
|
||||
export default class Hearts implements ICanvasEffect {
|
||||
private readonly options: HeartOptions;
|
||||
|
||||
public constructor(options: { [key: string]: any }) {
|
||||
this.options = { ...DefaultOptions, ...options };
|
||||
}
|
||||
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private particles: Array<Heart> = [];
|
||||
private lastAnimationTime = 0;
|
||||
|
||||
private colours = [
|
||||
"rgba(194,210,224,1)",
|
||||
"rgba(235,214,219,1)",
|
||||
"rgba(255,211,45,1)",
|
||||
"rgba(255,190,174,1)",
|
||||
"rgba(255,173,226,1)",
|
||||
"rgba(242,114,171,1)",
|
||||
"rgba(228,55,116,1)",
|
||||
"rgba(255,86,130,1)",
|
||||
"rgba(244,36,57,1)",
|
||||
"rgba(247,126,157,1)",
|
||||
"rgba(243,142,140,1)",
|
||||
"rgba(252,116,183,1)",
|
||||
];
|
||||
|
||||
public isRunning = false;
|
||||
|
||||
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise<void> => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.context = canvas.getContext("2d");
|
||||
this.particles = [];
|
||||
const count = this.options.maxCount;
|
||||
while (this.particles.length < count) {
|
||||
this.particles.push(this.resetParticle({} as Heart, canvas.width, canvas.height));
|
||||
}
|
||||
this.isRunning = true;
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
if (timeout) {
|
||||
window.setTimeout(this.stop, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
public stop = async (): Promise<void> => {
|
||||
this.isRunning = false;
|
||||
};
|
||||
|
||||
private resetParticle = (particle: Heart, width: number, height: number): Heart => {
|
||||
particle.color = this.colours[(Math.random() * this.colours.length) | 0];
|
||||
particle.x = Math.random() * width;
|
||||
particle.y = Math.random() * height + height;
|
||||
particle.xCol = particle.x;
|
||||
particle.scale = Math.random() * 0.07 + 0.04;
|
||||
particle.maximumDrift = Math.random() * this.options.maxDrift + 3.5;
|
||||
particle.maximumRot = Math.random() * this.options.maxRot + 3.5;
|
||||
particle.gravity = this.options.gravity + Math.random() * 4.8;
|
||||
return particle;
|
||||
};
|
||||
|
||||
private renderLoop = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.particles.length === 0) {
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
} else {
|
||||
const timeDelta = Date.now() - this.lastAnimationTime;
|
||||
if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) {
|
||||
// Clear the screen first
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
|
||||
this.lastAnimationTime = Date.now();
|
||||
this.animateAndRenderHearts();
|
||||
}
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
}
|
||||
};
|
||||
|
||||
private animateAndRenderHearts(): void {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
for (const particle of arrayFastClone(this.particles)) {
|
||||
particle.y -= particle.gravity;
|
||||
|
||||
// We treat the drift as a sine function to have a more fluid-like movement instead
|
||||
// of a pong-like movement off walls of the X column. This means that for
|
||||
// $x=A\sin(\frac{2\pi}{P}y)$ we use the `maximumDrift` as the amplitude (A) and a
|
||||
// large multiplier to create a very long waveform through P.
|
||||
const peakDistance = 75 * particle.maximumDrift;
|
||||
const PI2 = Math.PI * 2;
|
||||
particle.x = 6 * particle.maximumDrift * Math.sin(0.7 * (PI2 / peakDistance) * particle.y);
|
||||
particle.x += particle.xCol; // bring the particle to the right place
|
||||
|
||||
const posScale = 1 / particle.scale;
|
||||
const x = particle.x * posScale;
|
||||
const y = particle.y * posScale;
|
||||
|
||||
this.context.save();
|
||||
this.context.scale(particle.scale, particle.scale);
|
||||
this.context.beginPath();
|
||||
|
||||
// Rotate the heart about its centre.
|
||||
// The tilt of the heart is modelled similarly to its horizontal drift,
|
||||
// using a sine function.
|
||||
this.context.translate(248 + x, 215 + y);
|
||||
this.context.rotate((1 / 10) * particle.maximumRot * Math.sin((PI2 / peakDistance) * particle.y * 0.8));
|
||||
this.context.translate(-248 - x, -215 - y);
|
||||
|
||||
// Use bezier curves to draw a heart using pre-calculated coordinates.
|
||||
this.context.moveTo(140 + x, 20 + y);
|
||||
this.context.bezierCurveTo(73 + x, 20 + y, 20 + x, 74 + y, 20 + x, 140 + y);
|
||||
this.context.bezierCurveTo(20 + x, 275 + y, 156 + x, 310 + y, 248 + x, 443 + y);
|
||||
this.context.bezierCurveTo(336 + x, 311 + y, 477 + x, 270 + y, 477 + x, 140 + y);
|
||||
this.context.bezierCurveTo(477 + x, 74 + y, 423 + x, 20 + y, 357 + x, 20 + y);
|
||||
this.context.bezierCurveTo(309 + x, 20 + y, 267 + x, 48 + y, 248 + x, 89 + y);
|
||||
this.context.bezierCurveTo(229 + x, 48 + y, 188 + x, 20 + y, 140 + x, 20 + y);
|
||||
this.context.closePath();
|
||||
|
||||
this.context.fillStyle = particle.color;
|
||||
this.context.fill();
|
||||
|
||||
this.context.restore();
|
||||
|
||||
// Remove any dead hearts after a 100px wide margin.
|
||||
if (particle.y < -100) {
|
||||
const idx = this.particles.indexOf(particle);
|
||||
this.particles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import { _t, _td } from "../languageHandler";
|
||||
import { type ConfettiOptions } from "./confetti";
|
||||
import { type Effect } from "./effect";
|
||||
import { type FireworksOptions } from "./fireworks";
|
||||
import { type RainfallOptions } from "./rainfall";
|
||||
import { type SnowfallOptions } from "./snowfall";
|
||||
import { type SpaceInvadersOptions } from "./spaceinvaders";
|
||||
import { type HeartOptions } from "./hearts";
|
||||
|
||||
/**
|
||||
* This configuration defines room effects that can be triggered by custom message types and emojis
|
||||
*/
|
||||
export const CHAT_EFFECTS: Array<Effect<{ [key: string]: any }>> = [
|
||||
{
|
||||
emojis: ["🎊", "🎉"],
|
||||
msgType: "nic.custom.confetti",
|
||||
command: "confetti",
|
||||
description: () => _td("chat_effects|confetti_description"),
|
||||
fallbackMessage: () => _t("chat_effects|confetti_message") + " 🎉",
|
||||
options: {
|
||||
maxCount: 150,
|
||||
speed: 3,
|
||||
frameInterval: 15,
|
||||
alpha: 1.0,
|
||||
gradient: false,
|
||||
},
|
||||
} as Effect<ConfettiOptions>,
|
||||
{
|
||||
emojis: ["🎆"],
|
||||
msgType: "nic.custom.fireworks",
|
||||
command: "fireworks",
|
||||
description: () => _td("chat_effects|fireworks_description"),
|
||||
fallbackMessage: () => _t("chat_effects|fireworks_message") + " 🎆",
|
||||
options: {
|
||||
maxCount: 500,
|
||||
gravity: 0.05,
|
||||
},
|
||||
} as Effect<FireworksOptions>,
|
||||
{
|
||||
emojis: ["🌧️", "⛈️", "🌦️"],
|
||||
msgType: "io.element.effect.rainfall",
|
||||
command: "rainfall",
|
||||
description: () => _td("chat_effects|rainfall_description"),
|
||||
fallbackMessage: () => _t("chat_effects|rainfall_message") + " 🌧️",
|
||||
options: {
|
||||
maxCount: 600,
|
||||
speed: 10,
|
||||
},
|
||||
} as Effect<RainfallOptions>,
|
||||
{
|
||||
emojis: ["❄", "🌨"],
|
||||
msgType: "io.element.effect.snowfall",
|
||||
command: "snowfall",
|
||||
description: () => _td("chat_effects|snowfall_description"),
|
||||
fallbackMessage: () => _t("chat_effects|snowfall_message") + " ❄",
|
||||
options: {
|
||||
maxCount: 200,
|
||||
gravity: 0.05,
|
||||
maxDrift: 5,
|
||||
},
|
||||
} as Effect<SnowfallOptions>,
|
||||
{
|
||||
emojis: ["👾", "🌌"],
|
||||
msgType: "io.element.effects.space_invaders",
|
||||
command: "spaceinvaders",
|
||||
description: () => _td("chat_effects|spaceinvaders_description"),
|
||||
fallbackMessage: () => _t("chat_effects|spaceinvaders_message") + " 👾",
|
||||
options: {
|
||||
maxCount: 50,
|
||||
gravity: 0.01,
|
||||
},
|
||||
} as Effect<SpaceInvadersOptions>,
|
||||
{
|
||||
emojis: ["💝"],
|
||||
msgType: "io.element.effect.hearts",
|
||||
command: "hearts",
|
||||
description: () => _td("chat_effects|hearts_description"),
|
||||
fallbackMessage: () => _t("chat_effects|hearts_message") + " 💝",
|
||||
options: {
|
||||
maxCount: 120,
|
||||
gravity: 3.2,
|
||||
},
|
||||
} as Effect<HeartOptions>,
|
||||
];
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Josias Allestad
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import type ICanvasEffect from "../ICanvasEffect";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
|
||||
export type RainfallOptions = {
|
||||
/**
|
||||
* The maximum number of raindrops to render at a given time
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* The speed of the raindrops
|
||||
*/
|
||||
speed: number;
|
||||
};
|
||||
|
||||
type Raindrop = {
|
||||
x: number;
|
||||
y: number;
|
||||
height: number;
|
||||
width: number;
|
||||
speed: number;
|
||||
};
|
||||
|
||||
export const DefaultOptions: RainfallOptions = {
|
||||
maxCount: 600,
|
||||
speed: 12,
|
||||
};
|
||||
|
||||
const KEY_FRAME_INTERVAL = 15;
|
||||
|
||||
export default class Rainfall implements ICanvasEffect {
|
||||
private readonly options: RainfallOptions;
|
||||
|
||||
public constructor(options: { [key: string]: any }) {
|
||||
this.options = { ...DefaultOptions, ...options };
|
||||
}
|
||||
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private particles: Array<Raindrop> = [];
|
||||
private lastAnimationTime = 0;
|
||||
|
||||
public isRunning = false;
|
||||
|
||||
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise<void> => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.context = canvas.getContext("2d");
|
||||
this.particles = [];
|
||||
const count = this.options.maxCount;
|
||||
while (this.particles.length < count) {
|
||||
this.particles.push(this.resetParticle({} as Raindrop, canvas.width, canvas.height));
|
||||
}
|
||||
this.isRunning = true;
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
if (timeout) {
|
||||
window.setTimeout(this.stop, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
public stop = async (): Promise<void> => {
|
||||
this.isRunning = false;
|
||||
};
|
||||
|
||||
private resetParticle = (particle: Raindrop, width: number, height: number): Raindrop => {
|
||||
particle.x = Math.random() * width;
|
||||
particle.y = Math.random() * -height;
|
||||
particle.width = Math.random() * 1.5;
|
||||
particle.height = particle.width * 15 + 4;
|
||||
particle.speed = (Math.random() * this.options.speed * 4) / 5 + this.options.speed;
|
||||
return particle;
|
||||
};
|
||||
|
||||
private renderLoop = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.particles.length === 0) {
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
} else {
|
||||
const timeDelta = Date.now() - this.lastAnimationTime;
|
||||
if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) {
|
||||
// Clear the screen first
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
this.lastAnimationTime = Date.now();
|
||||
this.animateAndRenderRaindrops();
|
||||
}
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
}
|
||||
};
|
||||
|
||||
private animateAndRenderRaindrops = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
const height = this.context.canvas.height;
|
||||
for (const particle of arrayFastClone(this.particles)) {
|
||||
particle.y += particle.speed;
|
||||
|
||||
this.context.save();
|
||||
this.context.beginPath();
|
||||
this.context.rect(particle.x, particle.y, particle.width, particle.height);
|
||||
this.context.fillStyle = "#5dadec"; // light blue
|
||||
this.context.fill();
|
||||
this.context.closePath();
|
||||
this.context.restore();
|
||||
|
||||
// Remove dead raindrops
|
||||
const maxBounds = height * 2;
|
||||
if (particle.y > height + maxBounds) {
|
||||
const idx = this.particles.indexOf(particle);
|
||||
this.particles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import type ICanvasEffect from "../ICanvasEffect";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
|
||||
export type SnowfallOptions = {
|
||||
/**
|
||||
* The maximum number of snowflakes to render at a given time
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* The amount of gravity to apply to the snowflakes
|
||||
*/
|
||||
gravity: number;
|
||||
/**
|
||||
* The amount of drift (horizontal sway) to apply to the snowflakes. Each snowflake varies.
|
||||
*/
|
||||
maxDrift: number;
|
||||
};
|
||||
|
||||
type Snowflake = {
|
||||
x: number;
|
||||
y: number;
|
||||
xCol: number;
|
||||
diameter: number;
|
||||
maximumDrift: number;
|
||||
gravity: number;
|
||||
};
|
||||
|
||||
export const DefaultOptions: SnowfallOptions = {
|
||||
maxCount: 200,
|
||||
gravity: 0.05,
|
||||
maxDrift: 5,
|
||||
};
|
||||
|
||||
const KEY_FRAME_INTERVAL = 15; // 15ms, roughly
|
||||
|
||||
export default class Snowfall implements ICanvasEffect {
|
||||
private readonly options: SnowfallOptions;
|
||||
|
||||
public constructor(options: { [key: string]: any }) {
|
||||
this.options = { ...DefaultOptions, ...options };
|
||||
}
|
||||
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private particles: Array<Snowflake> = [];
|
||||
private lastAnimationTime = 0;
|
||||
|
||||
public isRunning = false;
|
||||
|
||||
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise<void> => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.context = canvas.getContext("2d");
|
||||
this.particles = [];
|
||||
const count = this.options.maxCount;
|
||||
while (this.particles.length < count) {
|
||||
this.particles.push(this.resetParticle({} as Snowflake, canvas.width, canvas.height));
|
||||
}
|
||||
this.isRunning = true;
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
if (timeout) {
|
||||
window.setTimeout(this.stop, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
public stop = async (): Promise<void> => {
|
||||
this.isRunning = false;
|
||||
};
|
||||
|
||||
private resetParticle = (particle: Snowflake, width: number, height: number): Snowflake => {
|
||||
particle.x = Math.random() * width;
|
||||
particle.y = Math.random() * -height;
|
||||
particle.xCol = particle.x;
|
||||
particle.diameter = Math.random() * 7 + 4;
|
||||
particle.maximumDrift = Math.random() * this.options.maxDrift + 3.5;
|
||||
particle.gravity = this.options.gravity + Math.random() * 6 + 4;
|
||||
return particle;
|
||||
};
|
||||
|
||||
private renderLoop = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.particles.length === 0) {
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
} else {
|
||||
const timeDelta = Date.now() - this.lastAnimationTime;
|
||||
if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) {
|
||||
// Clear the screen first
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
|
||||
this.lastAnimationTime = Date.now();
|
||||
this.animateAndRenderSnowflakes();
|
||||
}
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
}
|
||||
};
|
||||
|
||||
private animateAndRenderSnowflakes(): void {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
const height = this.context.canvas.height;
|
||||
for (const particle of arrayFastClone(this.particles)) {
|
||||
particle.y += particle.gravity;
|
||||
|
||||
// We treat the drift as a sine function to have a more fluid-like movement instead
|
||||
// of a pong-like movement off walls of the X column. This means that for
|
||||
// $x=A\sin(\frac{2\pi}{P}y)$ we use the `maximumDrift` as the amplitude (A) and a
|
||||
// large multiplier to create a very long waveform through P.
|
||||
const peakDistance = 75 * particle.maximumDrift;
|
||||
const PI2 = Math.PI * 2;
|
||||
particle.x = particle.maximumDrift * Math.sin((PI2 / peakDistance) * particle.y);
|
||||
particle.x += particle.xCol; // bring the particle to the right place
|
||||
|
||||
const radius = particle.diameter / 2;
|
||||
this.context.save();
|
||||
this.context.beginPath();
|
||||
this.context.ellipse(particle.x, particle.y, radius, radius, 0, 0, 360);
|
||||
this.context.fillStyle = "#eaeaea"; // grey so it shows up on the light theme
|
||||
this.context.fill();
|
||||
this.context.closePath();
|
||||
this.context.restore();
|
||||
|
||||
// Remove any dead snowflakes
|
||||
const maxBounds = radius * 4; // make sure it's *really* off screen
|
||||
if (particle.y > height + maxBounds) {
|
||||
const idx = this.particles.indexOf(particle);
|
||||
this.particles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import type ICanvasEffect from "../ICanvasEffect";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
|
||||
export type SpaceInvadersOptions = {
|
||||
/**
|
||||
* The maximum number of invaders to render at a given time
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* The amount of gravity to apply to the invaders
|
||||
*/
|
||||
gravity: number;
|
||||
};
|
||||
|
||||
type Invader = {
|
||||
x: number;
|
||||
y: number;
|
||||
xCol: number;
|
||||
gravity: number;
|
||||
};
|
||||
|
||||
export const DefaultOptions: SpaceInvadersOptions = {
|
||||
maxCount: 50,
|
||||
gravity: 0.005,
|
||||
};
|
||||
|
||||
const KEY_FRAME_INTERVAL = 15; // 15ms, roughly
|
||||
const GLYPH = "👾";
|
||||
|
||||
export default class SpaceInvaders implements ICanvasEffect {
|
||||
private readonly options: SpaceInvadersOptions;
|
||||
|
||||
public constructor(options: { [key: string]: any }) {
|
||||
this.options = { ...DefaultOptions, ...options };
|
||||
}
|
||||
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private particles: Array<Invader> = [];
|
||||
private lastAnimationTime = 0;
|
||||
|
||||
public isRunning = false;
|
||||
|
||||
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise<void> => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.context = canvas.getContext("2d");
|
||||
this.particles = [];
|
||||
const count = this.options.maxCount;
|
||||
while (this.particles.length < count) {
|
||||
this.particles.push(this.resetParticle({} as Invader, canvas.width, canvas.height));
|
||||
}
|
||||
this.isRunning = true;
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
if (timeout) {
|
||||
window.setTimeout(this.stop, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
public stop = async (): Promise<void> => {
|
||||
this.isRunning = false;
|
||||
};
|
||||
|
||||
private resetParticle = (particle: Invader, width: number, height: number): Invader => {
|
||||
particle.x = Math.random() * width;
|
||||
particle.y = Math.random() * -height;
|
||||
particle.xCol = particle.x;
|
||||
particle.gravity = this.options.gravity + Math.random() * 6 + 4;
|
||||
return particle;
|
||||
};
|
||||
|
||||
private renderLoop = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.particles.length === 0) {
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
} else {
|
||||
const timeDelta = Date.now() - this.lastAnimationTime;
|
||||
if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) {
|
||||
// Clear the screen first
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
|
||||
this.lastAnimationTime = Date.now();
|
||||
this.animateAndRenderInvaders();
|
||||
}
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
}
|
||||
};
|
||||
|
||||
private animateAndRenderInvaders(): void {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
this.context.font = "50px Twemoji";
|
||||
for (const particle of arrayFastClone(this.particles)) {
|
||||
particle.y += particle.gravity;
|
||||
|
||||
this.context.save();
|
||||
this.context.fillText(GLYPH, particle.x, particle.y);
|
||||
this.context.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type IContent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
/**
|
||||
* Checks a message if it contains one of the provided emojis
|
||||
* @param {Object} content The message
|
||||
* @param {Array<string>} emojis The list of emojis to check for
|
||||
*/
|
||||
export const containsEmoji = (content: IContent, emojis: Array<string>): boolean => {
|
||||
return emojis.some((emoji) => content.body && content.body.includes(emoji));
|
||||
};
|
||||
Reference in New Issue
Block a user