diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 9af5ebcbfb..e2ae875ac3 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -46,6 +46,7 @@ import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; import {UIFeature} from "./settings/UIFeature"; +import {CHAT_EFFECTS} from "./effects" import CallHandler from "./CallHandler"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 @@ -78,6 +79,7 @@ export const CommandCategories = { "actions": _td("Actions"), "admin": _td("Admin"), "advanced": _td("Advanced"), + "effects": _td("Effects"), "other": _td("Other"), }; @@ -1094,6 +1096,30 @@ export const Commands = [ category: CommandCategories.messages, hideCompletionAfterSpace: true, }), + + ...CHAT_EFFECTS.map((effect) => { + return new Command({ + command: effect.command, + description: effect.description(), + args: '<message>', + runFn: function(roomId, args) { + return success((async () => { + if (!args) { + args = effect.fallbackMessage(); + MatrixClientPeg.get().sendEmoteMessage(roomId, args); + } else { + const content = { + msgtype: effect.msgType, + body: args, + }; + MatrixClientPeg.get().sendMessage(roomId, content); + } + dis.dispatch({action: `effects.${effect.command}`}); + })()); + }, + category: CommandCategories.effects, + }) + }), ]; // build a map from names and aliases to the Command objects. diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index adcb401ec1..d2d473fd3d 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -69,6 +69,9 @@ import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; import {XOR} from "../../@types/common"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import EffectsOverlay from "../views/elements/EffectsOverlay"; +import {containsEmoji} from '../../effects/utils'; +import {CHAT_EFFECTS} from '../../effects'; import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import WidgetStore from "../../stores/WidgetStore"; import {UPDATE_EVENT} from "../../stores/AsyncStore"; @@ -248,6 +251,8 @@ export default class RoomView extends React.Component<IProps, IState> { 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); @@ -581,6 +586,8 @@ export default class RoomView extends React.Component<IProps, IState> { 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); @@ -781,6 +788,27 @@ export default class RoomView extends React.Component<IProps, IState> { } }; + private onEventDecrypted = (ev) => { + if (ev.isDecryptionFailure()) return; + this.handleEffects(ev); + }; + + private onEvent = (ev) => { + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + this.handleEffects(ev); + }; + + private handleEffects = (ev) => { + if (!this.state.room || + !this.state.matrixClientIsReady || + this.state.room.getUnreadNotificationCount() === 0) return; + CHAT_EFFECTS.forEach(effect => { + if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) { + dis.dispatch({action: `effects.${effect.command}`}); + } + }) + }; + private onRoomName = (room: Room) => { if (this.state.room && room.roomId == this.state.room.roomId) { this.forceUpdate(); @@ -1946,9 +1974,14 @@ export default class RoomView extends React.Component<IProps, IState> { mx_RoomView_inCall: Boolean(activeCall), }); + const showChatEffects = SettingsStore.getValue('showChatEffects'); + return ( <RoomContext.Provider value={this.state}> <main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}> + {showChatEffects && this.roomView.current && + <EffectsOverlay roomWidth={this.roomView.current.offsetWidth} /> + } <ErrorBoundary> <RoomHeader room={this.state.room} diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx new file mode 100644 index 0000000000..6297d80768 --- /dev/null +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -0,0 +1,94 @@ +/* + Copyright 2020 Nurjin Jafar + Copyright 2020 Nordeck IT + Consulting GmbH. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import React, { FunctionComponent, useEffect, useRef } from 'react'; +import dis from '../../../dispatcher/dispatcher'; +import ICanvasEffect from '../../../effects/ICanvasEffect'; +import {CHAT_EFFECTS} from '../../../effects' + +interface IProps { + roomWidth: number; +} + +const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const effectsRef = useRef<Map<string, ICanvasEffect>>(new Map<string, ICanvasEffect>()); + + const lazyLoadEffectModule = async (name: string): Promise<ICanvasEffect> => { + if (!name) return null; + let effect: ICanvasEffect | null = effectsRef.current[name] || null; + if (effect === null) { + const options = CHAT_EFFECTS.find((e) => e.command === name)?.options + try { + const { default: Effect } = await import(`../../../effects/${name}`); + effect = new Effect(options); + effectsRef.current[name] = effect; + } catch (err) { + console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err); + } + } + return effect; + }; + + useEffect(() => { + const resize = () => { + if (canvasRef.current) { + canvasRef.current.height = window.innerHeight; + } + }; + 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)); + } + } + const dispatcherRef = dis.register(onAction); + const canvas = canvasRef.current; + canvas.height = window.innerHeight; + window.addEventListener('resize', resize, true); + + return () => { + dis.unregister(dispatcherRef); + window.removeEventListener('resize', resize); + // eslint-disable-next-line react-hooks/exhaustive-deps + const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored + for (const effect in currentEffects) { + const effectModule: ICanvasEffect = currentEffects[effect]; + if (effectModule && effectModule.isRunning) { + effectModule.stop(); + } + } + }; + }, []); + + return ( + <canvas + ref={canvasRef} + width={roomWidth} + style={{ + display: 'block', + zIndex: 999999, + pointerEvents: 'none', + position: 'fixed', + top: 0, + right: 0, + }} + /> + ) +} + +export default EffectsOverlay; diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index e88060304a..8171da7eca 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -42,6 +42,8 @@ import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; +import {containsEmoji} from "../../../effects/utils"; +import {CHAT_EFFECTS} from '../../../effects'; import SettingsStore from "../../../settings/SettingsStore"; import CountlyAnalytics from "../../../CountlyAnalytics"; @@ -326,6 +328,11 @@ export default class SendMessageComposer extends React.Component { }); } dis.dispatch({action: "message_sent"}); + CHAT_EFFECTS.forEach((effect) => { + if (containsEmoji(content, effect.emojis)) { + dis.dispatch({action: `effects.${effect.command}`}); + } + }); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); } diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index d6a4921f1a..4d8493401e 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -50,6 +50,7 @@ export default class PreferencesUserSettingsTab extends React.Component { 'showAvatarChanges', 'showDisplaynameChanges', 'showImages', + 'showChatEffects', 'Pill.shouldShowPillAvatar', ]; diff --git a/src/effects/ICanvasEffect.ts b/src/effects/ICanvasEffect.ts new file mode 100644 index 0000000000..9bf3e9293d --- /dev/null +++ b/src/effects/ICanvasEffect.ts @@ -0,0 +1,47 @@ +/* + Copyright 2020 Nurjin Jafar + Copyright 2020 Nordeck IT + Consulting GmbH. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * Defines the constructor of a canvas based room effect + */ +export interface ICanvasEffectConstructable { + /** + * @param {{[key:string]:any}} options? Optional animation options + * @returns ICanvasEffect Returns a new instance of the canvas effect + */ + new(options?: { [key: string]: any }): ICanvasEffect; +} + +/** + * 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; +} diff --git a/src/effects/confetti/index.ts b/src/effects/confetti/index.ts new file mode 100644 index 0000000000..8c4b2d2616 --- /dev/null +++ b/src/effects/confetti/index.ts @@ -0,0 +1,191 @@ +/* + Copyright 2020 Nurjin Jafar + Copyright 2020 Nordeck IT + Consulting GmbH. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import 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; + + 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: boolean; + + public start = async (canvas: HTMLCanvasElement, timeout = 3000) => { + 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 () => { + 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 = () => { + 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--; + } + } + } + } +} diff --git a/src/effects/index.ts b/src/effects/index.ts new file mode 100644 index 0000000000..16a0851070 --- /dev/null +++ b/src/effects/index.ts @@ -0,0 +1,89 @@ +/* + Copyright 2020 Nurjin Jafar + Copyright 2020 Nordeck IT + Consulting GmbH. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import { _t, _td } from "../languageHandler"; + +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 translated description of the effect + */ + description: () => string; + /** + * 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; +} + +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, +} + +/** + * 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("Sends the given message with confetti"), + fallbackMessage: () => _t("sends confetti") + " 🎉", + options: { + maxCount: 150, + speed: 3, + frameInterval: 15, + alpha: 1.0, + gradient: false, + }, + } as Effect<ConfettiOptions>, +]; + + diff --git a/src/effects/utils.ts b/src/effects/utils.ts new file mode 100644 index 0000000000..c2b499b154 --- /dev/null +++ b/src/effects/utils.ts @@ -0,0 +1,24 @@ +/* + Copyright 2020 Nurjin Jafar + Copyright 2020 Nordeck IT + Consulting GmbH. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +/** + * 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: { msgtype: string, body: string }, emojis: Array<string>): boolean => { + return emojis.some((emoji) => content.body && content.body.includes(emoji)); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4ef720da65..1b5d4b6ec4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -406,6 +406,7 @@ "Messages": "Messages", "Actions": "Actions", "Advanced": "Advanced", + "Effects": "Effects", "Other": "Other", "Command error": "Command error", "Usage": "Usage", @@ -826,6 +827,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", + "Show chat effects": "Show chat effects", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading logs": "Uploading logs", @@ -844,6 +846,8 @@ "When rooms are upgraded": "When rooms are upgraded", "My Ban List": "My Ban List", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", + "Sends the given message with confetti": "Sends the given message with confetti", + "sends confetti": "sends confetti", "Video Call": "Video Call", "Voice Call": "Voice Call", "Fill Screen": "Fill Screen", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 31e133be72..6bec31a1cb 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -634,6 +634,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Enable experimental, compact IRC style layout"), default: false, }, + "showChatEffects": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td("Show chat effects"), + default: true, + }, "Widgets.pinned": { supportedLevels: LEVELS_ROOM_OR_ACCOUNT, default: {},