diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 87dc1ccdfd..316249d74d 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -77,6 +77,7 @@ export const CommandCategories = { "actions": _td("Actions"), "admin": _td("Admin"), "advanced": _td("Advanced"), + "effects": _td("Effects"), "other": _td("Other"), }; @@ -1045,19 +1046,16 @@ export const Commands = [ args: '', runFn: function(roomId, args) { return success((async () => { - const isChatEffectsDisabled = SettingsStore.getValue('dontShowChatEffects'); - if ((!args) || (!args && isChatEffectsDisabled)) { + if (!args) { args = _t("sends confetti"); MatrixClientPeg.get().sendEmoteMessage(roomId, args); } else { MatrixClientPeg.get().sendTextMessage(roomId, args); } - if (!isChatEffectsDisabled) { - dis.dispatch({action: 'confetti'}); - } + dis.dispatch({action: 'effects.confetti'}); })()); }, - category: CommandCategories.actions, + category: CommandCategories.effects, }), // Command definitions for autocompletion ONLY: diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 0905005cf7..1b47386789 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -56,7 +56,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils'; import {Action} from "../../dispatcher/actions"; import {SettingLevel} from "../../settings/SettingLevel"; -import {animateConfetti, forceStopConfetti, isConfettiEmoji} from "../views/elements/Confetti"; import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; import {IMatrixClientCreds} from "../../MatrixClientPeg"; import ScrollPanel from "./ScrollPanel"; @@ -73,7 +72,7 @@ import TintableSvg from "../views/elements/TintableSvg"; import {XOR} from "../../@types/common"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call"; -import ConfettiOverlay from "../views/elements/ConfettiOverlay"; +import EffectsOverlay from "../views/elements/effects/EffectsOverlay"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -248,8 +247,6 @@ export default class RoomView extends React.Component { this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); - this.context.on("Event.decrypted", this.onEventDecrypted); - this.context.on("event", this.onEvent); // Start listening for RoomViewStore updates this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); @@ -570,8 +567,6 @@ export default class RoomView extends React.Component { this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); - this.context.removeListener("Event.decrypted", this.onEventDecrypted); - this.context.removeListener("event", this.onEvent); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -693,9 +688,6 @@ export default class RoomView extends React.Component { case 'message_sent': this.checkIfAlone(this.state.room); break; - case 'confetti': - //TODO: animateConfetti(this.roomView.current.offsetWidth); - break; case 'post_sticker_message': this.injectSticker( payload.data.content.url, @@ -804,28 +796,6 @@ export default class RoomView extends React.Component { } }; - private onEventDecrypted = (ev) => { - if (!SettingsStore.getValue('dontShowChatEffects')) { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || - this.state.room.getUnreadNotificationCount() === 0) return; - this.handleConfetti(ev); - } - }; - - private onEvent = (ev) => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; - this.handleConfetti(ev); - }; - - private handleConfetti = (ev) => { - if (this.state.matrixClientIsReady) { - const messageBody = _t('sends confetti'); - if (isConfettiEmoji(ev.getContent()) || ev.getContent().body === messageBody) { - dis.dispatch({action: 'confetti'}); - } - } - }; - private onRoomName = (room: Room) => { if (this.state.room && room.roomId == this.state.room.roomId) { this.forceUpdate(); @@ -2070,11 +2040,13 @@ export default class RoomView extends React.Component { mx_RoomView_inCall: Boolean(activeCall), }); + const showChatEffects = SettingsStore.getValue('showChatEffects'); + return (
- {this.roomView.current && - + {showChatEffects && this.roomView.current && + } confetti.frameInterval) { - context.clearRect(0, 0, window.innerWidth, window.innerHeight); - updateParticles(); - drawParticles(context); - lastFrameTime = now - (delta % confetti.frameInterval); - } - requestAnimationFrame(runAnimation); - } - } - - function startConfetti(canvas, roomWidth, timeout) { - window.requestAnimationFrame = (function() { - return window.requestAnimationFrame || - window.webkitRequestAnimationFrame || - window.mozRequestAnimationFrame || - window.oRequestAnimationFrame || - window.msRequestAnimationFrame || - function(callback) { - return window.setTimeout(callback, confetti.frameInterval); - }; - })(); - if (context === null) { - context = canvas.getContext("2d"); - } - const count = confetti.maxCount; - while (particles.length < count) { - particles.push(resetParticle({}, canvas.width, canvas.height)); - } - streamingConfetti = true; - runAnimation(); - if (timeout) { - window.setTimeout(stopConfetti, timeout); - } - } - - function stopConfetti() { - streamingConfetti = false; - } - - function removeConfetti() { - stop(); - particles = []; - } - - function isConfettiRunning() { - return streamingConfetti; - } - - function drawParticles(context) { - let particle; - let x; let x2; let y2; - for (let i = 0; i < particles.length; i++) { - particle = particles[i]; - 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 (confetti.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(); - } - } - - function updateParticles() { - const width = window.innerWidth; - const height = window.innerHeight; - let particle; - waveAngle += 0.01; - for (let i = 0; i < particles.length; i++) { - particle = particles[i]; - if (!streamingConfetti && particle.y < -15) { - particle.y = height + 100; - } else { - particle.tiltAngle += particle.tiltAngleIncrement; - particle.x += Math.sin(waveAngle) - 0.5; - particle.y += (Math.cos(waveAngle) + particle.diameter + confetti.speed) * 0.5; - particle.tilt = Math.sin(particle.tiltAngle) * 15; - } - if (particle.x > width + 20 || particle.x < -20 || particle.y > height) { - if (streamingConfetti && particles.length <= confetti.maxCount) { - resetParticle(particle, width, height); - } else { - particles.splice(i, 1); - i--; - } - } - } - } -})(); - -export function convertToHex(content) { - const contentBodyToHexArray = []; - let hex; - if (content.body) { - for (let i = 0; i < content.body.length; i++) { - hex = content.body.codePointAt(i).toString(16); - contentBodyToHexArray.push(hex); - } - } - return contentBodyToHexArray; -} - -export function isConfettiEmoji(content) { - const hexArray = convertToHex(content); - return !!(hexArray.includes('1f389') || hexArray.includes('1f38a')); -} - -export function animateConfetti(canvas, roomWidth) { - confetti.start(canvas, roomWidth, 3000); -} -export function forceStopConfetti() { - confetti.remove(); -} diff --git a/src/components/views/elements/ConfettiOverlay.tsx b/src/components/views/elements/ConfettiOverlay.tsx deleted file mode 100644 index 63d38d834c..0000000000 --- a/src/components/views/elements/ConfettiOverlay.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import {animateConfetti, forceStopConfetti} from './Confetti.js'; - -export default function ConfettiOverlay({roomWidth}) { - const canvasRef = useRef(null); - // on mount - useEffect(() => { - const resize = () => { - const canvas = canvasRef.current; - canvas.height = window.innerHeight; - }; - const canvas = canvasRef.current; - canvas.width = roomWidth; - canvas.height = window.innerHeight; - window.addEventListener("resize", resize, true); - animateConfetti(canvas, roomWidth); - return () => { - window.removeEventListener("resize", resize); - forceStopConfetti(); - }; - }, []); - // on roomWidth change - - useEffect(() => { - const canvas = canvasRef.current; - canvas.width = roomWidth; - }, [roomWidth]); - return ( - - ) -} \ No newline at end of file diff --git a/src/components/views/elements/effects/EffectsOverlay.tsx b/src/components/views/elements/effects/EffectsOverlay.tsx new file mode 100644 index 0000000000..1f8e7a97ad --- /dev/null +++ b/src/components/views/elements/effects/EffectsOverlay.tsx @@ -0,0 +1,77 @@ +import React, {FunctionComponent, useEffect, useRef} from 'react'; +import dis from '../../../../dispatcher/dispatcher'; +import ICanvasEffect from './ICanvasEffect.js'; + +type EffectsOverlayProps = { + roomWidth: number; +} + +const EffectsOverlay: FunctionComponent = ({roomWidth}) => { + const canvasRef = useRef(null); + const effectsRef = useRef>(new Map()); + + const resize = () => { + canvasRef.current.height = window.innerHeight; + }; + + const lazyLoadEffectModule = async (name: string): Promise => { + if(!name) return null; + let effect = effectsRef.current[name] ?? null; + if(effect === null) { + try { + var { default: Effect } = await import(`./${name}`); + effect = new Effect(); + effectsRef.current[name] = effect; + } catch (err) { + console.warn('Unable to load effect module at \'./${name}\'.', err) + } + } + return effect; + } + + const onAction = (payload: { action: string }) => { + const actionPrefix = 'effects.'; + if(payload.action.indexOf(actionPrefix) === 0) { + const effect = payload.action.substr(actionPrefix.length); + lazyLoadEffectModule(effect).then((module) => module?.start(canvasRef.current)); + } + }; + + // on mount + useEffect(() => { + const dispatcherRef = dis.register(onAction); + const canvas = canvasRef.current; + canvas.width = roomWidth; + canvas.height = window.innerHeight; + window.addEventListener('resize', resize, true); + + return () => { + dis.unregister(dispatcherRef); + window.removeEventListener('resize', resize); + for(const effect in effectsRef.current) { + effectsRef.current[effect]?.stop(); + } + }; + }, []); + + // on roomWidth change + useEffect(() => { + canvasRef.current.width = roomWidth; + }, [roomWidth]); + + return ( + + ) +} + +export default EffectsOverlay; \ No newline at end of file diff --git a/src/components/views/elements/effects/ICanvasEffect.ts b/src/components/views/elements/effects/ICanvasEffect.ts new file mode 100644 index 0000000000..c463235880 --- /dev/null +++ b/src/components/views/elements/effects/ICanvasEffect.ts @@ -0,0 +1,5 @@ +export default interface ICanvasEffect { + start: (canvas: HTMLCanvasElement, timeout?: number) => Promise, + stop: () => Promise, + isRunning: boolean +} \ No newline at end of file diff --git a/src/components/views/elements/effects/confetti/index.ts b/src/components/views/elements/effects/confetti/index.ts new file mode 100644 index 0000000000..dd4e869078 --- /dev/null +++ b/src/components/views/elements/effects/confetti/index.ts @@ -0,0 +1,197 @@ +import ICanvasEffect from '../ICanvasEffect' + +declare global { + interface Window { + mozRequestAnimationFrame: any; + oRequestAnimationFrame: any; + msRequestAnimationFrame: any; + } +} + +export type ConfettiOptions = { + maxCount: number, + speed: number, + frameInterval: number, + alpha: number, + gradient: boolean, +} + +type ConfettiParticle = { + color: string, + color2: string, + x: number, + y: number, + diameter: number, + tilt: number, + tiltAngleIncrement: number, + tiltAngle: number, +} + +const DefaultOptions: ConfettiOptions = { + //set max confetti count + maxCount: 150, + //syarn addet the particle animation speed + speed: 3, + //the confetti animation frame interval in milliseconds + frameInterval: 15, + //the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible) + alpha: 1.0, + //use gradient instead of solid particle color + gradient: false, +}; + +export default class Confetti implements ICanvasEffect { + private readonly options: ConfettiOptions; + + constructor(options: ConfettiOptions = DefaultOptions) { + this.options = options; + } + + private context: CanvasRenderingContext2D | null; + private supportsAnimationFrame = window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame; + 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 = []; + private waveAngle = 0; + + public isRunning: boolean; + + public start = async (canvas: HTMLCanvasElement, timeout?: number) => { + if(!canvas) { + return; + } + window.requestAnimationFrame = (function () { + return window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function (callback) { + return window.setTimeout(callback, this.options.frameInterval); + }; + })(); + if (this.context === null) { + this.context = canvas.getContext('2d'); + } + 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 || 3000); + } + } + + public stop = async () => { + this.isRunning = false; + this.particles = []; + } + + private resetParticle = (particle: ConfettiParticle, width: number, height: number): ConfettiParticle => { + particle.color = this.colors[(Math.random() * this.colors.length) | 0] + (this.options.alpha + ')'); + particle.color2 = this.colors[(Math.random() * this.colors.length) | 0] + (this.options.alpha + ')'); + particle.x = Math.random() * width; + particle.y = Math.random() * height - height; + particle.diameter = Math.random() * 10 + 5; + particle.tilt = Math.random() * 10 - 10; + particle.tiltAngleIncrement = Math.random() * 0.07 + 0.05; + particle.tiltAngle = Math.random() * Math.PI; + return particle; + } + + private runAnimation = (): void => { + if (this.particles.length === 0) { + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + //animationTimer = null; + } 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 => { + let particle; + let x; let x2; let y2; + for (let i = 0; i < this.particles.length; i++) { + particle = this.particles[i]; + 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 = () => { + 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--; + } + } + } + } +} + +const convertToHex = (data: string): Array => { + const contentBodyToHexArray = []; + if (!data) return contentBodyToHexArray; + let hex; + if (data) { + for (let i = 0; i < data.length; i++) { + hex = data.codePointAt(i).toString(16); + contentBodyToHexArray.push(hex); + } + } + return contentBodyToHexArray; +} + +export const isConfettiEmoji = (content: { msgtype: string, body: string }): boolean => { + const hexArray = convertToHex(content.body); + return !!(hexArray.includes('1f389') || hexArray.includes('1f38a')); +} diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index d148d38b23..4fbea9d043 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -42,7 +42,7 @@ import {Key} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; -import {isConfettiEmoji} from "../elements/Confetti"; +import {isConfettiEmoji} from "../elements/effects/confetti"; import SettingsStore from "../../../settings/SettingsStore"; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { @@ -318,10 +318,8 @@ export default class SendMessageComposer extends React.Component { }); } dis.dispatch({action: "message_sent"}); - if (!SettingsStore.getValue('dontShowChatEffects')) { - if (isConfettiEmoji(content)) { - dis.dispatch({action: 'confetti'}); - } + if (isConfettiEmoji(content)) { + dis.dispatch({action: 'effects.confetti'}); } } diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index 95d0f4be46..078d4dd2c7 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -49,7 +49,7 @@ export default class PreferencesUserSettingsTab extends React.Component { 'showAvatarChanges', 'showDisplaynameChanges', 'showImages', - 'dontShowChatEffects', + 'showChatEffects', 'Pill.shouldShowPillAvatar', ]; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4fc7a3ad25..c3943eb764 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -510,7 +510,7 @@ "Manually verify all remote sessions": "Manually verify all remote sessions", "IRC display name width": "IRC display name width", "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout", - "Don't show chat effects": "Don't show chat effects", + "Show chat effects": "Show chat effects", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading logs": "Uploading logs", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index b9ad834c83..ab4665c401 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -622,10 +622,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Enable experimental, compact IRC style layout"), default: false, }, - "dontShowChatEffects": { + "showChatEffects": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td("Don't show chat effects"), - default: false, + displayName: _td("Show chat effects"), + default: true, }, "Widgets.pinned": { supportedLevels: LEVELS_ROOM_OR_ACCOUNT,