Merge branch 'feature_confetti#14676' into develop

This commit is contained in:
Travis Ralston 2020-12-07 15:12:45 -07:00
commit 87dbc82a9f
11 changed files with 521 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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);
}

View file

@ -50,6 +50,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'showAvatarChanges',
'showDisplaynameChanges',
'showImages',
'showChatEffects',
'Pill.shouldShowPillAvatar',
];

View file

@ -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;
}

View file

@ -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--;
}
}
}
}
}

89
src/effects/index.ts Normal file
View file

@ -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>,
];

24
src/effects/utils.ts Normal file
View file

@ -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));
}

View file

@ -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",

View file

@ -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: {},