Unverified Commit d1138107 authored by Nyaaori's avatar Nyaaori
Browse files

Implement Custom Emotes

parent 10fa56e0
Pipeline #155 failed with stages
in 8 seconds
/*
Copyright 2021 Revolution (Nyaaori, Sorunome)
Copyright 2021 chat.horse
Licensed under the Cooperative Non-Violent Public License v7+ (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License with this software or at
https://thufie.lain.haus/files/CNPLv7.md
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.
*/
.mx_EmotesUserSettingsTab .mx_AccessibleButton {
// Pad out buttons a bit
margin-right: 10px;
margin-top: 10px;
}
.mx_EmotesUserSettingsTab .mx_Field_input {
// Fix spacing between emote entries
margin-top: 0px;
margin-bottom: 0px;
}
.mx_EmotesPanel_file > input {
display: none;
}
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017, 2018 New Vector Ltd
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
......@@ -13,6 +14,11 @@ 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.
Additionally, original modifications by Revolution are licensed under the CNPLv7+.
See https://thufie.lain.haus/files/CNPLv7.md or the provided LICENSE for additional information.
These modifications may only be redistributed and used within the terms of
the Cooperative Non-Violent Public License as distributed with this project.
*/
import { ReactElement } from 'react';
......@@ -23,6 +29,7 @@ import CommunityProvider from './CommunityProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import EmoteProvider from './EmoteProvider';
import NotifProvider from './NotifProvider';
import { timeout } from "../utils/promise";
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
......@@ -36,7 +43,7 @@ export interface ISelectionRange {
}
export interface ICompletion {
type: "at-room" | "command" | "community" | "room" | "user";
type: "at-room" | "command" | "community" | "room" | "user" | "emote";
completion: string;
completionId?: string;
component?: ReactElement;
......@@ -52,6 +59,7 @@ const PROVIDERS = [
UserProvider,
RoomProvider,
EmojiProvider,
EmoteProvider,
NotifProvider,
CommandProvider,
];
......
/*
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Cooperative Non-Violent Public License v7+ (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License with this software or at
https://thufie.lain.haus/files/CNPLv7.md
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 from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import { MatrixClientPeg } from '../MatrixClientPeg';
import { PillCompletion } from './Components';
import { ICompletion, ISelectionRange } from './Autocompleter';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import RoomViewStore from "../stores/RoomViewStore";
const EMOTE_REGEX = /(\S+)/g;
const LIMIT = 20;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class EmoteProvider extends AutocompleteProvider {
client: any;
emoteData: Array<any>;
fn: any;
roomFn: any;
constructor() {
super(EMOTE_REGEX);
this.client = MatrixClientPeg.get();
this.emoteData = [];
this.loadEmotes();
this.listenChanges();
}
loadEmotes() {
this.emoteData = [];
const normalizeEmotePackName = (name) => {
name = name.replace(/ /g, '-');
name = name.replace(/[^\w-]/g, '');
return name.toLowerCase();
};
const allMxcs = new Set();
const addEmotePack = (packName, content, packNameOverride?) => {
const emotes = content.images || content.emoticons;
if (!emotes && !content.short) {
return;
}
if (content.pack && content.pack.name) {
packName = content.pack.name;
}
if (packNameOverride) {
packName = packNameOverride;
}
packName = normalizeEmotePackName(packName);
if (emotes) {
for (const key of Object.keys(emotes)) {
if (emotes[key].url && emotes[key].url.startsWith('mxc://') && !allMxcs.has(emotes[key].url)) {
allMxcs.add(emotes[key].url);
this.emoteData.push({
code: key[0] === ':' ? key : `:${key}:`,
mxc: emotes[key].url,
packName: packName,
});
}
}
} else {
for (const key of Object.keys(content.short)) {
if (content.short[key] && content.short[key].startsWith('mxc://') && !allMxcs.has(content.short[key])) {
allMxcs.add(content.short[key]);
this.emoteData.push({
code: key,
mxc: content.short[key],
packName: packName,
});
}
}
}
};
// first add the user emotes
const userEmotes = this.client.getAccountData('im.ponies.user_emotes');
if (userEmotes && !userEmotes.error && userEmotes.event.content) {
addEmotePack('user', userEmotes.event.content);
}
// next add the external room emotes
const emoteRooms = this.client.getAccountData('im.ponies.emote_rooms');
if (emoteRooms && !emoteRooms.error && emoteRooms.event.content && emoteRooms.event.content.rooms) {
for (const roomId of Object.keys(emoteRooms.event.content.rooms)) {
const room = this.client.getRoom(roomId);
if (!room) {
continue;
}
for (const stateKey of Object.keys(emoteRooms.event.content.rooms[roomId])) {
let event = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
if (event) {
event = event.event || event;
addEmotePack(roomId, event.content, emoteRooms.event.content.rooms[roomId][stateKey]['name']);
}
}
}
}
// finally add all the room emotes
const thisRoomId = RoomViewStore.getRoomId();
const thisRoom = this.client.getRoom(thisRoomId);
if (thisRoom) {
const events = thisRoom.currentState.getStateEvents('im.ponies.room_emotes');
for (let event of events) {
event = event.event || event;
addEmotePack(event.state_key || 'room', event.content);
}
}
}
listenChanges() {
this.fn = (event) => {
this.loadEmotes();
};
this.client.on("accountData", this.fn);
this.roomFn = (event, room) => {
if ((event.event || event).type === 'im.ponies.room_emotes') {
this.loadEmotes();
}
};
this.client.on("Room.timeline", this.roomFn);
}
match(s) {
if (s.length == 0) {
return [];
}
const firstChar = s[0];
s = s.toLowerCase().substring(1);
const results = [];
this.emoteData.forEach((e) => {
if (e.code[0] == firstChar && e.code.toLowerCase().includes(s)) {
results.push(e);
}
});
return results;
}
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
let completions = [];
const { command, range } = this.getCurrentCommand(query, selection);
if (command) {
const EmoteAvatar = sdk.getComponent('views.avatars.EmoteAvatar');
const matchedString = command[1];
completions = this.match(matchedString);
try {
completions = _sortBy(completions, [
(c) => score(matchedString, c.code),
(c) => c.code.length,
]).slice(0, LIMIT).map((result) => {
const mxc: string = result.mxc;
const code: string = result.code;
const packName: string = result.packName;
return {
completion: code,
completionId: mxc,
type: 'emote',
suffix: ' ',
href: mxc,
component:
<PillCompletion title={`${code} (${packName})`}>
<EmoteAvatar width={24} height={24} mxcUrl={mxc} name={code} />
</PillCompletion>,
range,
};
});
} catch (e) {
console.error(e);
completions = [];
}
}
return completions;
}
getName() {
return _t('Emotes');
}
renderCompletions(completions: React.Component[]): React.ReactNode {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
</div>;
}
destroy() {
this.client.off("accountData", this.fn);
this.client.off("Room.timeline", this.roomFn);
}
}
/*
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Cooperative Non-Violent Public License v7+ (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License with this software or at
https://thufie.lain.haus/files/CNPLv7.md
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 from 'react';
import { mediaFromMxc } from "../../../customisations/Media";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import BaseAvatar from './BaseAvatar';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
interface IProps {
name: string;
mxcUrl: string;
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
}
@replaceableComponent("views.avatars.EmoteAvatar")
export default class EmoteAvatar extends React.Component<IProps> {
public static defaultProps = {
width: 24,
height: 24,
resizeMethod: 'crop',
};
render() {
return <BaseAvatar name={this.props.name} idName={this.props.name} url={this.getEmoteAvatarUrl()} />
}
getEmoteAvatarUrl() {
return mediaFromMxc(this.props.mxcUrl)
.getThumbnailOfSourceHttp(this.props.width, this.props.height, this.props.resizeMethod)
}
}
/*
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
......@@ -13,6 +14,11 @@ 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.
Additionally, original modifications by Revolution are licensed under the CNPLv7+.
See https://thufie.lain.haus/files/CNPLv7.md or the provided LICENSE for additional information.
These modifications may only be redistributed and used within the terms of
the Cooperative Non-Violent Public License as distributed with this project.
*/
import React from 'react';
......@@ -24,6 +30,7 @@ import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab
import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab";
import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsTab";
import BridgeSettingsTab from "../settings/tabs/room/BridgeSettingsTab";
import EmotesRoomSettingsTab from "../settings/tabs/room/EmotesRoomSettingsTab";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
......@@ -36,6 +43,7 @@ export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB";
export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB";
export const ROOM_EMOTES_TAB = "ROOM_EMOTES_TAB";
export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB";
interface IProps {
......@@ -116,6 +124,13 @@ export default class RoomSettingsDialog extends React.Component<IProps, IState>
<NotificationSettingsTab roomId={this.props.roomId} />,
));
tabs.push(new Tab(
ROOM_EMOTES_TAB,
_td("Emotes"),
"mx_MessageComposer_emoji",
<EmotesRoomSettingsTab roomId={this.props.roomId} />,
));
if (SettingsStore.getValue("feature_bridge_state")) {
tabs.push(new Tab(
ROOM_BRIDGES_TAB,
......
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
......@@ -13,6 +14,11 @@ 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.
Additionally, original modifications by Revolution are licensed under the CNPLv7+.
See https://thufie.lain.haus/files/CNPLv7.md or the provided LICENSE for additional information.
These modifications may only be redistributed and used within the terms of
the Cooperative Non-Violent Public License as distributed with this project.
*/
import React from 'react';
......@@ -28,6 +34,7 @@ import PreferencesUserSettingsTab from "../settings/tabs/user/PreferencesUserSet
import VoiceUserSettingsTab from "../settings/tabs/user/VoiceUserSettingsTab";
import HelpUserSettingsTab from "../settings/tabs/user/HelpUserSettingsTab";
import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab";
import EmotesUserSettingsTab from "../settings/tabs/user/EmotesUserSettingsTab";
import SdkConfig from "../../../SdkConfig";
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
import { UIFeature } from "../../../settings/UIFeature";
......@@ -46,6 +53,7 @@ export enum UserTab {
Labs = "USER_LABS_TAB",
Mjolnir = "USER_MJOLNIR_TAB",
Help = "USER_HELP_TAB",
Emotes = "USER_EMOTES_TAB",
}
interface IProps extends IDialogProps {
......@@ -132,6 +140,14 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
"mx_UserSettingsDialog_securityIcon",
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
tabs.push(new Tab(
UserTab.Emotes,
_td("Emotes"),
"mx_MessageComposer_emoji",
<EmotesUserSettingsTab />,
))
// Show the Labs tab if enabled or if there are any active betas
if (SdkConfig.get()['showLabsSettings']
|| SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k))
......
/*
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Cooperative Non-Violent Public License v7+ (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License with this software or at
https://thufie.lain.haus/files/CNPLv7.md
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 from 'react';
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps {
url: string;
alt: string;
}
const REGEX_EMOTE = /^emote:\/\/(.*)$/;
export default class Emote extends React.Component<IProps> {
static defaultProps = {};
static isEmoteUrl(url) {
return !!REGEX_EMOTE.exec(url);
}
render() {
if (this.props.url != "") {
const url = mediaFromMxc(this.props.url)
.getThumbnailOfSourceHttp(800, 32);
return <img src={url} height={32} alt={this.props.alt} title={this.props.alt} />;
}
return null;
}
}
/*
Copyright 2017 - 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
......@@ -12,7 +13,13 @@ 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.
Additionally, original modifications by Revolution are licensed under the CNPLv7+.
See https://thufie.lain.haus/files/CNPLv7.md or the provided LICENSE for additional information.
These modifications may only be redistributed and used within the terms of
the Cooperative Non-Violent Public License as distributed with this project.
*/
import React from 'react';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
......@@ -59,6 +66,8 @@ class Pill extends React.Component {
shouldShowPillAvatar: PropTypes.bool,
// Whether to render this pill as if it were highlit by a selection
isSelected: PropTypes.bool,
// the content the pill shall have
content: PropTypes.string,
};
state = {
......@@ -122,7 +131,7 @@ class Pill extends React.Component {
const localRoom = resourceId[0] === '#' ?
MatrixClientPeg.get().getRooms().find((r) => {
return r.getCanonicalAlias() === resourceId ||
r.getAltAliases().includes(resourceId);
r.getAltAliases().includes(resourceId);
}) : MatrixClientPeg.get().getRoom(resourceId);
room = localRoom;
if (!localRoom) {
......@@ -287,7 +296,7 @@ class Pill extends React.Component {
}
return <MatrixClientContext.Provider value={this._matrixClient}>
{ this.props.inMessage ?
{this.props.inMessage ?
<a
className={classes}
href={href}
......@@ -296,9 +305,9 @@ class Pill extends React.Component {
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
{ avatar }
{ linkText }
{ tip }
{avatar}
{linkText}
{tip}
</a> :
<span
className={classes}
......@@ -306,10 +315,10 @@ class Pill extends React.Component {
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
{ avatar }
{ linkText }
{ tip }
</span> }
{avatar}
{linkText}
{tip}
</span>}
</MatrixClientContext.Provider>;
} else {
// Deliberately render nothing if the URL isn't recognised
......
/*
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Revolution (Nyaaori, Sorunome)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
......@@ -12,6 +13,11 @@ 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.
Additionally, original modifications by Revolution are licensed under the CNPLv7+.
See https://thufie.lain.haus/files/CNPLv7.md or the provided LICENSE for additional information.
These modifications may only be redistributed and used within the terms of
the Cooperative Non-Violent Public License as distributed with this project.
*/
import React from "react";
......@@ -25,6 +31,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps {
// The event we're displaying reactions for
......@@ -90,6 +97,16 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
render() {
const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props;
let contentElem: any = content;
if (content.startsWith("mxc://")) {
const size = 18;
const url = mediaFromMxc(content).getThumbnailOfSourceHttp(200, size, "scale");
contentElem = <img
src={url}
height={size}
/>;
}
const classes = classNames({
mx_ReactionsRowButton: true,
mx_ReactionsRowButton_selected: !!myReactionEvent,
......@@ -111,7 +128,7 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
<