Custom emoji & Sticker support (#686)

* Remove comments

* Show custom emoji first in suggestions

* Show global image packs in emoji picker

* Display emoji and sticker in room settings

* Fix some pack not visible in emojiboard

* WIP

* Add/delete/rename images to exisitng packs

* Change pack avatar, name & attribution

* Add checkbox to make pack global

* Bug fix

* Create or delete pack

* Add personal emoji in settings

* Show global pack selector in settings

* Show space emoji in emojiboard

* Send custom emoji reaction as mxc

* Render stickers as stickers

* Fix sticker jump bug

* Fix reaction width

* Fix stretched custom emoji

* Fix sending space emoji in message

* Remove unnessesary comments

* Send user pills

* Fix pill generating regex

* Add support for sending stickers
This commit is contained in:
Ajay Bura 2022-08-06 09:04:23 +05:30 committed by GitHub
parent 5e527e434a
commit edace32213
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1781 additions and 203 deletions

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 3L21 8V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H16ZM19 9H17C15.8954 9 15 8.10457 15 7V5H5V19H19V9Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12H9Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View file

@ -26,11 +26,11 @@
&--icon { &--icon {
@include dir.side(padding, var(--sp-tight), var(--sp-loose)); @include dir.side(padding, var(--sp-tight), var(--sp-loose));
}
.ic-raw { .ic-raw {
@include dir.side(margin, 0, var(--sp-extra-tight)); @include dir.side(margin, 0, var(--sp-extra-tight));
flex-shrink: 0; flex-shrink: 0;
} }
}
} }
@mixin color($textColor, $iconColor) { @mixin color($textColor, $iconColor) {

View file

@ -0,0 +1,469 @@
import React, {
useState, useMemo, useReducer, useEffect,
} from 'react';
import PropTypes from 'prop-types';
import './ImagePack.scss';
import initMatrix from '../../../client/initMatrix';
import { openReusableDialog } from '../../../client/action/navigation';
import { suffixRename } from '../../../util/common';
import Button from '../../atoms/button/Button';
import Text from '../../atoms/text/Text';
import Input from '../../atoms/input/Input';
import Checkbox from '../../atoms/button/Checkbox';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import { ImagePack as ImagePackBuilder } from '../../organisms/emoji-board/custom-emoji';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
import ImagePackProfile from './ImagePackProfile';
import ImagePackItem from './ImagePackItem';
import ImagePackUpload from './ImagePackUpload';
const renameImagePackItem = (shortcode) => new Promise((resolve) => {
let isCompleted = false;
openReusableDialog(
<Text variant="s1" weight="medium">Rename</Text>,
(requestClose) => (
<div style={{ padding: 'var(--sp-normal)' }}>
<form
onSubmit={(e) => {
e.preventDefault();
const sc = e.target.shortcode.value;
if (sc.trim() === '') return;
isCompleted = true;
resolve(sc.trim());
requestClose();
}}
>
<Input
value={shortcode}
name="shortcode"
label="Shortcode"
autoFocus
required
/>
<div style={{ height: 'var(--sp-normal)' }} />
<Button variant="primary" type="submit">Rename</Button>
</form>
</div>
),
() => {
if (!isCompleted) resolve(null);
},
);
});
function getUsage(usage) {
if (usage.includes('emoticon') && usage.includes('sticker')) return 'both';
if (usage.includes('emoticon')) return 'emoticon';
if (usage.includes('sticker')) return 'sticker';
return 'both';
}
function isGlobalPack(roomId, stateKey) {
const mx = initMatrix.matrixClient;
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
if (typeof globalContent !== 'object') return false;
const { rooms } = globalContent;
if (typeof rooms !== 'object') return false;
return rooms[roomId]?.[stateKey] !== undefined;
}
function useRoomImagePack(roomId, stateKey) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const packEvent = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
const pack = useMemo(() => (
ImagePackBuilder.parsePack(packEvent.getId(), packEvent.getContent())
), [room, stateKey]);
const sendPackContent = (content) => {
mx.sendStateEvent(roomId, 'im.ponies.room_emotes', content, stateKey);
};
return {
pack,
sendPackContent,
};
}
function useUserImagePack() {
const mx = initMatrix.matrixClient;
const packEvent = mx.getAccountData('im.ponies.user_emotes');
const pack = useMemo(() => (
ImagePackBuilder.parsePack(mx.getUserId(), packEvent?.getContent() ?? {
pack: { display_name: 'Personal' },
images: {},
})
), []);
const sendPackContent = (content) => {
mx.setAccountData('im.ponies.user_emotes', content);
};
return {
pack,
sendPackContent,
};
}
function useImagePackHandles(pack, sendPackContent) {
const [, forceUpdate] = useReducer((count) => count + 1, 0);
const getNewKey = (key) => {
if (typeof key !== 'string') return undefined;
let newKey = key?.replace(/\s/g, '-');
if (pack.getImages().get(newKey)) {
newKey = suffixRename(
newKey,
(suffixedKey) => pack.getImages().get(suffixedKey),
);
}
return newKey;
};
const handleAvatarChange = (url) => {
pack.setAvatarUrl(url);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleEditProfile = (name, attribution) => {
pack.setDisplayName(name);
pack.setAttribution(attribution);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleUsageChange = (newUsage) => {
const usage = [];
if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
pack.setUsage(usage);
pack.getImages().forEach((img) => pack.setImageUsage(img.shortcode, undefined));
sendPackContent(pack.getContent());
forceUpdate();
};
const handleRenameItem = async (key) => {
const newKey = getNewKey(await renameImagePackItem(key));
if (!newKey || newKey === key) return;
pack.updateImageKey(key, newKey);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleDeleteItem = async (key) => {
const isConfirmed = await confirmDialog(
'Delete',
`Are you sure that you want to delete "${key}"?`,
'Delete',
'danger',
);
if (!isConfirmed) return;
pack.removeImage(key);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleUsageItem = (key, newUsage) => {
const usage = [];
if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
pack.setImageUsage(key, usage);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleAddItem = (key, url) => {
const newKey = getNewKey(key);
if (!newKey || !url) return;
pack.addImage(newKey, {
url,
});
sendPackContent(pack.getContent());
forceUpdate();
};
return {
handleAvatarChange,
handleEditProfile,
handleUsageChange,
handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
};
}
function addGlobalImagePack(mx, roomId, stateKey) {
const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
if (!content.rooms) content.rooms = {};
if (!content.rooms[roomId]) content.rooms[roomId] = {};
content.rooms[roomId][stateKey] = {};
return mx.setAccountData('im.ponies.emote_rooms', content);
}
function removeGlobalImagePack(mx, roomId, stateKey) {
const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
if (!content.rooms) return Promise.resolve();
if (!content.rooms[roomId]) return Promise.resolve();
delete content.rooms[roomId][stateKey];
if (Object.keys(content.rooms[roomId]).length === 0) {
delete content.rooms[roomId];
}
return mx.setAccountData('im.ponies.emote_rooms', content);
}
function ImagePack({ roomId, stateKey, handlePackDelete }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const [viewMore, setViewMore] = useState(false);
const [isGlobal, setIsGlobal] = useState(isGlobalPack(roomId, stateKey));
const { pack, sendPackContent } = useRoomImagePack(roomId, stateKey);
const {
handleAvatarChange,
handleEditProfile,
handleUsageChange,
handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
} = useImagePackHandles(pack, sendPackContent);
const handleGlobalChange = (isG) => {
setIsGlobal(isG);
if (isG) addGlobalImagePack(mx, roomId, stateKey);
else removeGlobalImagePack(mx, roomId, stateKey);
};
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
const handleDeletePack = async () => {
const isConfirmed = await confirmDialog(
'Delete Pack',
`Are you sure that you want to delete "${pack.displayName}"?`,
'Delete',
'danger',
);
if (!isConfirmed) return;
handlePackDelete(stateKey);
};
const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
return (
<div className="image-pack">
<ImagePackProfile
avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
displayName={pack.displayName ?? 'Unknown'}
attribution={pack.attribution}
usage={getUsage(pack.usage)}
onUsageChange={canChange ? handleUsageChange : null}
onAvatarChange={canChange ? handleAvatarChange : null}
onEditProfile={canChange ? handleEditProfile : null}
/>
{ canChange && (
<ImagePackUpload onUpload={handleAddItem} />
)}
{ images.length === 0 ? null : (
<div>
<div className="image-pack__header">
<Text variant="b3">Image</Text>
<Text variant="b3">Shortcode</Text>
<Text variant="b3">Usage</Text>
</div>
{images.map(([shortcode, image]) => (
<ImagePackItem
key={shortcode}
url={mx.mxcUrlToHttp(image.mxc)}
shortcode={shortcode}
usage={getUsage(image.usage)}
onUsageChange={canChange ? handleUsageItem : undefined}
onDelete={canChange ? handleDeleteItem : undefined}
onRename={canChange ? handleRenameItem : undefined}
/>
))}
</div>
)}
{(pack.images.size > 2 || handlePackDelete) && (
<div className="image-pack__footer">
{pack.images.size > 2 && (
<Button onClick={() => setViewMore(!viewMore)}>
{
viewMore
? 'View less'
: `View ${pack.images.size - 2} more`
}
</Button>
)}
{ handlePackDelete && <Button variant="danger" onClick={handleDeletePack}>Delete Pack</Button>}
</div>
)}
<div className="image-pack__global">
<Checkbox variant="positive" onToggle={handleGlobalChange} isActive={isGlobal} />
<div>
<Text variant="b2">Use globally</Text>
<Text variant="b3">Add this pack to your account to use in all rooms.</Text>
</div>
</div>
</div>
);
}
ImagePack.defaultProps = {
handlePackDelete: null,
};
ImagePack.propTypes = {
roomId: PropTypes.string.isRequired,
stateKey: PropTypes.string.isRequired,
handlePackDelete: PropTypes.func,
};
function ImagePackUser() {
const mx = initMatrix.matrixClient;
const [viewMore, setViewMore] = useState(false);
const { pack, sendPackContent } = useUserImagePack();
const {
handleAvatarChange,
handleEditProfile,
handleUsageChange,
handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
} = useImagePackHandles(pack, sendPackContent);
const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
return (
<div className="image-pack">
<ImagePackProfile
avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
displayName={pack.displayName ?? 'Personal'}
attribution={pack.attribution}
usage={getUsage(pack.usage)}
onUsageChange={handleUsageChange}
onAvatarChange={handleAvatarChange}
onEditProfile={handleEditProfile}
/>
<ImagePackUpload onUpload={handleAddItem} />
{ images.length === 0 ? null : (
<div>
<div className="image-pack__header">
<Text variant="b3">Image</Text>
<Text variant="b3">Shortcode</Text>
<Text variant="b3">Usage</Text>
</div>
{images.map(([shortcode, image]) => (
<ImagePackItem
key={shortcode}
url={mx.mxcUrlToHttp(image.mxc)}
shortcode={shortcode}
usage={getUsage(image.usage)}
onUsageChange={handleUsageItem}
onDelete={handleDeleteItem}
onRename={handleRenameItem}
/>
))}
</div>
)}
{(pack.images.size > 2) && (
<div className="image-pack__footer">
<Button onClick={() => setViewMore(!viewMore)}>
{
viewMore
? 'View less'
: `View ${pack.images.size - 2} more`
}
</Button>
</div>
)}
</div>
);
}
function useGlobalImagePack() {
const [, forceUpdate] = useReducer((count) => count + 1, 0);
const mx = initMatrix.matrixClient;
const roomIdToStateKeys = new Map();
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? { rooms: {} };
const { rooms } = globalContent;
Object.keys(rooms).forEach((roomId) => {
if (typeof rooms[roomId] !== 'object') return;
const room = mx.getRoom(roomId);
const stateKeys = Object.keys(rooms[roomId]);
if (!room || stateKeys.length === 0) return;
roomIdToStateKeys.set(roomId, stateKeys);
});
useEffect(() => {
const handleEvent = (event) => {
if (event.getType() === 'im.ponies.emote_rooms') forceUpdate();
};
mx.addListener('accountData', handleEvent);
return () => {
mx.removeListener('accountData', handleEvent);
};
}, []);
return roomIdToStateKeys;
}
function ImagePackGlobal() {
const mx = initMatrix.matrixClient;
const roomIdToStateKeys = useGlobalImagePack();
const handleChange = (roomId, stateKey) => {
removeGlobalImagePack(mx, roomId, stateKey);
};
return (
<div className="image-pack-global">
<MenuHeader>Global packs</MenuHeader>
<div>
{
roomIdToStateKeys.size > 0
? [...roomIdToStateKeys].map(([roomId, stateKeys]) => {
const room = mx.getRoom(roomId);
return (
stateKeys.map((stateKey) => {
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
const pack = ImagePackBuilder.parsePack(data?.getId(), data?.getContent());
if (!pack) return null;
return (
<div className="image-pack__global" key={pack.id}>
<Checkbox variant="positive" onToggle={() => handleChange(roomId, stateKey)} isActive />
<div>
<Text variant="b2">{pack.displayName ?? 'Unknown'}</Text>
<Text variant="b3">{room.name}</Text>
</div>
</div>
);
})
);
})
: <div className="image-pack-global__empty"><Text>No global packs</Text></div>
}
</div>
</div>
);
}
export default ImagePack;
export { ImagePackUser, ImagePackGlobal };

View file

@ -0,0 +1,47 @@
@use '../../partials/flex';
.image-pack {
&-item {
border-top: 1px solid var(--bg-surface-border);
}
&__header {
padding: var(--sp-extra-tight) var(--sp-normal);
display: flex;
align-items: center;
gap: var(--sp-normal);
& > *:nth-child(2) {
@extend .cp-fx__item-one;
}
}
&__footer {
padding: var(--sp-normal);
display: flex;
justify-content: space-between;
gap: var(--sp-tight);
}
&__global {
padding: var(--sp-normal);
padding-top: var(--sp-tight);
display: flex;
align-items: center;
gap: var(--sp-normal);
}
}
.image-pack-global {
&__empty {
text-align: center;
padding: var(--sp-extra-loose) var(--sp-normal);
}
& .image-pack__global {
padding: 0 var(--sp-normal);
padding-bottom: var(--sp-normal);
&:first-child {
padding-top: var(--sp-normal);
}
}
}

View file

@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import './ImagePackItem.scss';
import { openReusableContextMenu } from '../../../client/action/navigation';
import { getEventCords } from '../../../util/common';
import Avatar from '../../atoms/avatar/Avatar';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import ImagePackUsageSelector from './ImagePackUsageSelector';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
function ImagePackItem({
url, shortcode, usage, onUsageChange, onDelete, onRename,
}) {
const handleUsageSelect = (event) => {
openReusableContextMenu(
'bottom',
getEventCords(event, '.btn-surface'),
(closeMenu) => (
<ImagePackUsageSelector
usage={usage}
onSelect={(newUsage) => {
onUsageChange(shortcode, newUsage);
closeMenu();
}}
/>
),
);
};
return (
<div className="image-pack-item">
<Avatar imageSrc={url} size="extra-small" text={shortcode} bgColor="black" />
<div className="image-pack-item__content">
<Text>{shortcode}</Text>
</div>
<div className="image-pack-item__usage">
<div className="image-pack-item__btn">
{onRename && <IconButton tooltip="Rename" size="extra-small" src={PencilIC} onClick={() => onRename(shortcode)} />}
{onDelete && <IconButton tooltip="Delete" size="extra-small" src={BinIC} onClick={() => onDelete(shortcode)} />}
</div>
<Button onClick={onUsageChange ? handleUsageSelect : undefined}>
{onUsageChange && <RawIcon src={ChevronBottomIC} size="extra-small" />}
<Text variant="b2">
{usage === 'emoticon' && 'Emoji'}
{usage === 'sticker' && 'Sticker'}
{usage === 'both' && 'Both'}
</Text>
</Button>
</div>
</div>
);
}
ImagePackItem.defaultProps = {
onUsageChange: null,
onDelete: null,
onRename: null,
};
ImagePackItem.propTypes = {
url: PropTypes.string.isRequired,
shortcode: PropTypes.string.isRequired,
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
onUsageChange: PropTypes.func,
onDelete: PropTypes.func,
onRename: PropTypes.func,
};
export default ImagePackItem;

View file

@ -0,0 +1,43 @@
@use '../../partials/flex';
@use '../../partials/dir';
.image-pack-item {
margin: 0 var(--sp-normal);
padding: var(--sp-tight) 0;
display: flex;
align-items: center;
gap: var(--sp-normal);
& .avatar-container img {
object-fit: contain;
border-radius: 0;
}
&__content {
@extend .cp-fx__item-one;
}
&__usage {
display: flex;
gap: var(--sp-ultra-tight);
& button {
padding: 6px;
}
& > button.btn-surface {
padding: 6px var(--sp-tight);
min-width: 0;
@include dir.side(margin, var(--sp-ultra-tight), 0);
}
}
&__btn {
display: none;
}
&:hover,
&:focus-within {
.image-pack-item__btn {
display: flex;
gap: var(--sp-ultra-tight);
}
}
}

View file

@ -0,0 +1,125 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './ImagePackProfile.scss';
import { openReusableContextMenu } from '../../../client/action/navigation';
import { getEventCords } from '../../../util/common';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import ImageUpload from '../image-upload/ImageUpload';
import ImagePackUsageSelector from './ImagePackUsageSelector';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
function ImagePackProfile({
avatarUrl, displayName, attribution, usage,
onUsageChange, onAvatarChange, onEditProfile,
}) {
const [isEdit, setIsEdit] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
const { nameInput, attributionInput } = e.target;
const name = nameInput.value.trim() || undefined;
const att = attributionInput.value.trim() || undefined;
onEditProfile(name, att);
setIsEdit(false);
};
const handleUsageSelect = (event) => {
openReusableContextMenu(
'bottom',
getEventCords(event, '.btn-surface'),
(closeMenu) => (
<ImagePackUsageSelector
usage={usage}
onSelect={(newUsage) => {
onUsageChange(newUsage);
closeMenu();
}}
/>
),
);
};
return (
<div className="image-pack-profile">
{
onAvatarChange
? (
<ImageUpload
bgColor="#555"
text={displayName}
imageSrc={avatarUrl}
size="normal"
onUpload={onAvatarChange}
onRequestRemove={() => onAvatarChange(undefined)}
/>
)
: <Avatar bgColor="#555" text={displayName} imageSrc={avatarUrl} size="normal" />
}
<div className="image-pack-profile__content">
{
isEdit
? (
<form onSubmit={handleSubmit}>
<Input name="nameInput" label="Name" value={displayName} required />
<Input name="attributionInput" label="Attribution" value={attribution} resizable />
<div>
<Button variant="primary" type="submit">Save</Button>
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
</div>
</form>
) : (
<>
<div>
<Text>{displayName}</Text>
{onEditProfile && <IconButton size="extra-small" onClick={() => setIsEdit(true)} src={PencilIC} tooltip="Edit" />}
</div>
{attribution && <Text variant="b3">{attribution}</Text>}
</>
)
}
</div>
<div className="image-pack-profile__usage">
<Text variant="b3">Pack usage</Text>
<Button
onClick={onUsageChange ? handleUsageSelect : undefined}
iconSrc={onUsageChange ? ChevronBottomIC : null}
>
<Text>
{usage === 'emoticon' && 'Emoji'}
{usage === 'sticker' && 'Sticker'}
{usage === 'both' && 'Both'}
</Text>
</Button>
</div>
</div>
);
}
ImagePackProfile.defaultProps = {
avatarUrl: null,
attribution: null,
onUsageChange: null,
onAvatarChange: null,
onEditProfile: null,
};
ImagePackProfile.propTypes = {
avatarUrl: PropTypes.string,
displayName: PropTypes.string.isRequired,
attribution: PropTypes.string,
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
onUsageChange: PropTypes.func,
onAvatarChange: PropTypes.func,
onEditProfile: PropTypes.func,
};
export default ImagePackProfile;

View file

@ -0,0 +1,37 @@
@use '../../partials/flex';
.image-pack-profile {
padding: var(--sp-normal);
display: flex;
align-items: flex-start;
gap: var(--sp-tight);
&__content {
@extend .cp-fx__item-one;
& > div:first-child {
display: flex;
align-items: center;
gap: var(--sp-extra-tight);
& .ic-btn {
padding: var(--sp-ultra-tight);
}
}
& > form {
display: flex;
flex-direction: column;
gap: var(--sp-extra-tight);
& > div:last-child {
margin: var(--sp-extra-tight) 0;
display: flex;
gap: var(--sp-tight);
}
}
}
&__usage {
& > *:first-child {
margin-bottom: var(--sp-ultra-tight);
}
}
}

View file

@ -0,0 +1,73 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import './ImagePackUpload.scss';
import initMatrix from '../../../client/initMatrix';
import { scaleDownImage } from '../../../util/common';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import IconButton from '../../atoms/button/IconButton';
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
function ImagePackUpload({ onUpload }) {
const mx = initMatrix.matrixClient;
const inputRef = useRef(null);
const shortcodeRef = useRef(null);
const [imgFile, setImgFile] = useState(null);
const [progress, setProgress] = useState(false);
const handleSubmit = async (evt) => {
evt.preventDefault();
if (!imgFile) return;
const { shortcodeInput } = evt.target;
const shortcode = shortcodeInput.value.trim();
if (shortcode === '') return;
setProgress(true);
const image = await scaleDownImage(imgFile, 512, 512);
const url = await mx.uploadContent(image, {
onlyContentUri: true,
});
onUpload(shortcode, url);
setProgress(false);
setImgFile(null);
shortcodeRef.current.value = '';
};
const handleFileChange = (evt) => {
const img = evt.target.files[0];
if (!img) return;
setImgFile(img);
shortcodeRef.current.focus();
};
const handleRemove = () => {
setImgFile(null);
inputRef.current.value = null;
};
return (
<form onSubmit={handleSubmit} className="image-pack-upload">
<input ref={inputRef} onChange={handleFileChange} style={{ display: 'none' }} type="file" accept=".png, .gif, .webp" required />
{
imgFile
? (
<div className="image-pack-upload__file">
<IconButton onClick={handleRemove} src={CirclePlusIC} tooltip="Remove file" />
<Text>{imgFile.name}</Text>
</div>
)
: <Button onClick={() => inputRef.current.click()}>Import image</Button>
}
<Input forwardRef={shortcodeRef} name="shortcodeInput" placeholder="shortcode" required />
<Button disabled={progress} variant="primary" type="submit">{progress ? 'Uploading...' : 'Upload'}</Button>
</form>
);
}
ImagePackUpload.propTypes = {
onUpload: PropTypes.func.isRequired,
};
export default ImagePackUpload;

View file

@ -0,0 +1,43 @@
@use '../../partials/dir';
@use '../../partials/text';
.image-pack-upload {
padding: var(--sp-normal);
padding-top: 0;
display: flex;
gap: var(--sp-tight);
& > .input-container {
flex-grow: 1;
input {
padding: 9px var(--sp-normal);
}
}
&__file {
display: inline-flex;
align-items: center;
background: var(--bg-surface-low);
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
& button {
--parent-height: 40px;
width: var(--parent-height);
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
& .ic-raw {
background-color: var(--bg-caution);
transform: rotate(45deg);
}
& .text {
@extend .cp-txt__ellipsis;
@include dir.side(margin, var(--sp-ultra-tight), var(--sp-normal));
max-width: 86px;
}
}
}

View file

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import CheckIC from '../../../../public/res/ic/outlined/check.svg';
function ImagePackUsageSelector({ usage, onSelect }) {
return (
<div>
<MenuHeader>Usage</MenuHeader>
<MenuItem
iconSrc={usage === 'emoticon' ? CheckIC : undefined}
variant={usage === 'emoticon' ? 'positive' : 'surface'}
onClick={() => onSelect('emoticon')}
>
Emoji
</MenuItem>
<MenuItem
iconSrc={usage === 'sticker' ? CheckIC : undefined}
variant={usage === 'sticker' ? 'positive' : 'surface'}
onClick={() => onSelect('sticker')}
>
Sticker
</MenuItem>
<MenuItem
iconSrc={usage === 'both' ? CheckIC : undefined}
variant={usage === 'both' ? 'positive' : 'surface'}
onClick={() => onSelect('both')}
>
Both
</MenuItem>
</div>
);
}
ImagePackUsageSelector.propTypes = {
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
onSelect: PropTypes.func.isRequired,
};
export default ImagePackUsageSelector;

View file

@ -7,9 +7,13 @@ import initMatrix from '../../../client/initMatrix';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
import Spinner from '../../atoms/spinner/Spinner'; import Spinner from '../../atoms/spinner/Spinner';
import RawIcon from '../../atoms/system-icons/RawIcon';
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
function ImageUpload({ function ImageUpload({
text, bgColor, imageSrc, onUpload, onRequestRemove, text, bgColor, imageSrc, onUpload, onRequestRemove,
size,
}) { }) {
const [uploadPromise, setUploadPromise] = useState(null); const [uploadPromise, setUploadPromise] = useState(null);
const uploadImageRef = useRef(null); const uploadImageRef = useRef(null);
@ -50,10 +54,14 @@ function ImageUpload({
imageSrc={imageSrc} imageSrc={imageSrc}
text={text} text={text}
bgColor={bgColor} bgColor={bgColor}
size="large" size={size}
/> />
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}> <div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
{uploadPromise === null && <Text variant="b3" weight="bold">Upload</Text>} {uploadPromise === null && (
size === 'large'
? <Text variant="b3" weight="bold">Upload</Text>
: <RawIcon src={PlusIC} color="white" />
)}
{uploadPromise !== null && <Spinner size="small" />} {uploadPromise !== null && <Spinner size="small" />}
</div> </div>
</button> </button>
@ -75,6 +83,7 @@ ImageUpload.defaultProps = {
text: null, text: null,
bgColor: 'transparent', bgColor: 'transparent',
imageSrc: null, imageSrc: null,
size: 'large',
}; };
ImageUpload.propTypes = { ImageUpload.propTypes = {
@ -83,6 +92,7 @@ ImageUpload.propTypes = {
imageSrc: PropTypes.string, imageSrc: PropTypes.string,
onUpload: PropTypes.func.isRequired, onUpload: PropTypes.func.isRequired,
onRequestRemove: PropTypes.func.isRequired, onRequestRemove: PropTypes.func.isRequired,
size: PropTypes.oneOf(['large', 'normal']),
}; };
export default ImageUpload; export default ImageUpload;

View file

@ -69,9 +69,8 @@ async function getUrl(link, type, decryptData) {
} }
} }
function getNativeHeight(width, height) { function getNativeHeight(width, height, maxWidth = 296) {
const MEDIA_MAX_WIDTH = 296; const scale = maxWidth / width;
const scale = MEDIA_MAX_WIDTH / width;
return scale * height; return scale * height;
} }
@ -196,6 +195,45 @@ Image.propTypes = {
type: PropTypes.string, type: PropTypes.string,
}; };
function Sticker({
name, height, width, link, file, type,
}) {
const [url, setUrl] = useState(null);
useEffect(() => {
let unmounted = false;
async function fetchUrl() {
const myUrl = await getUrl(link, type, file);
if (unmounted) return;
setUrl(myUrl);
}
fetchUrl();
return () => {
unmounted = true;
};
}, []);
return (
<div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
{ url !== null && <img src={url || link} title={name} alt={name} />}
</div>
);
}
Sticker.defaultProps = {
file: null,
type: '',
};
Sticker.propTypes = {
name: PropTypes.string.isRequired,
width: null,
height: null,
width: PropTypes.number,
height: PropTypes.number,
link: PropTypes.string.isRequired,
file: PropTypes.shape({}),
type: PropTypes.string,
};
function Audio({ function Audio({
name, link, type, file, name, link, type, file,
}) { }) {
@ -315,5 +353,5 @@ Video.propTypes = {
}; };
export { export {
File, Image, Audio, Video, File, Image, Sticker, Audio, Video,
}; };

View file

@ -42,6 +42,15 @@
background-size: cover; background-size: cover;
} }
.sticker-container {
display: inline-flex;
max-width: 128px;
width: 100%;
& img {
width: 100% !important;
}
}
.image-container { .image-container {
& img { & img {
max-width: unset !important; max-width: unset !important;

View file

@ -5,7 +5,6 @@ import React, {
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Message.scss'; import './Message.scss';
import { getShortcodeToCustomEmoji } from '../../organisms/emoji-board/custom-emoji';
import { twemojify } from '../../../util/twemojify'; import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
@ -322,7 +321,7 @@ function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
return rEvent; return rEvent;
} }
function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) { function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) {
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline); const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
if (myAlreadyReactEvent) { if (myAlreadyReactEvent) {
const rId = myAlreadyReactEvent.getId(); const rId = myAlreadyReactEvent.getId();
@ -330,17 +329,17 @@ function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
redactEvent(roomId, rId); redactEvent(roomId, rId);
return; return;
} }
sendReaction(roomId, eventId, emojiKey); sendReaction(roomId, eventId, emojiKey, shortcode);
} }
function pickEmoji(e, roomId, eventId, roomTimeline) { function pickEmoji(e, roomId, eventId, roomTimeline) {
openEmojiBoard(getEventCords(e), (emoji) => { openEmojiBoard(getEventCords(e), (emoji) => {
toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline); toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline);
e.target.click(); e.target.click();
}); });
} }
function genReactionMsg(userIds, reaction) { function genReactionMsg(userIds, reaction, shortcode) {
return ( return (
<> <>
{userIds.map((userId, index) => ( {userIds.map((userId, index) => (
@ -354,24 +353,22 @@ function genReactionMsg(userIds, reaction) {
</React.Fragment> </React.Fragment>
))} ))}
<span style={{ opacity: '.6' }}>{' reacted with '}</span> <span style={{ opacity: '.6' }}>{' reacted with '}</span>
{twemojify(reaction, { className: 'react-emoji' })} {twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
</> </>
); );
} }
function MessageReaction({ function MessageReaction({
shortcodeToEmoji, reaction, count, users, isActive, onClick, reaction, shortcode, count, users, isActive, onClick,
}) { }) {
const customEmojiMatch = reaction.match(/^:(\S+):$/);
let customEmojiUrl = null; let customEmojiUrl = null;
if (customEmojiMatch) { if (reaction.match(/^mxc:\/\/\S+$/)) {
const customEmoji = shortcodeToEmoji.get(customEmojiMatch[1]); customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(customEmoji?.mxc);
} }
return ( return (
<Tooltip <Tooltip
className="msg__reaction-tooltip" className="msg__reaction-tooltip"
content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}</Text>} content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}</Text>}
> >
<button <button
onClick={onClick} onClick={onClick}
@ -380,7 +377,7 @@ function MessageReaction({
> >
{ {
customEmojiUrl customEmojiUrl
? <img className="react-emoji" draggable="false" alt={reaction} src={customEmojiUrl} /> ? <img className="react-emoji" draggable="false" alt={shortcode ?? reaction} src={customEmojiUrl} />
: twemojify(reaction, { className: 'react-emoji' }) : twemojify(reaction, { className: 'react-emoji' })
} }
<Text variant="b3" className="msg__reaction-count">{count}</Text> <Text variant="b3" className="msg__reaction-count">{count}</Text>
@ -388,9 +385,12 @@ function MessageReaction({
</Tooltip> </Tooltip>
); );
} }
MessageReaction.defaultProps = {
shortcode: undefined,
};
MessageReaction.propTypes = { MessageReaction.propTypes = {
shortcodeToEmoji: PropTypes.shape({}).isRequired,
reaction: PropTypes.node.isRequired, reaction: PropTypes.node.isRequired,
shortcode: PropTypes.string,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
users: PropTypes.arrayOf(PropTypes.string).isRequired, users: PropTypes.arrayOf(PropTypes.string).isRequired,
isActive: PropTypes.bool.isRequired, isActive: PropTypes.bool.isRequired,
@ -401,11 +401,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
const { roomId, room, reactionTimeline } = roomTimeline; const { roomId, room, reactionTimeline } = roomTimeline;
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const reactions = {}; const reactions = {};
const shortcodeToEmoji = getShortcodeToCustomEmoji(room);
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId()); const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
const eventReactions = reactionTimeline.get(mEvent.getId()); const eventReactions = reactionTimeline.get(mEvent.getId());
const addReaction = (key, count, senderId, isActive) => { const addReaction = (key, shortcode, count, senderId, isActive) => {
let reaction = reactions[key]; let reaction = reactions[key];
if (reaction === undefined) { if (reaction === undefined) {
reaction = { reaction = {
@ -414,6 +413,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
isActive: false, isActive: false,
}; };
} }
if (shortcode) reaction.shortcode = shortcode;
if (count) { if (count) {
reaction.count = count; reaction.count = count;
} else { } else {
@ -429,9 +429,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
if (rEvent.getRelation() === null) return; if (rEvent.getRelation() === null) return;
const reaction = rEvent.getRelation(); const reaction = rEvent.getRelation();
const senderId = rEvent.getSender(); const senderId = rEvent.getSender();
const { shortcode } = rEvent.getContent();
const isActive = senderId === mx.getUserId(); const isActive = senderId === mx.getUserId();
addReaction(reaction.key, undefined, senderId, isActive); addReaction(reaction.key, shortcode, undefined, senderId, isActive);
}); });
} else { } else {
// Use aggregated reactions // Use aggregated reactions
@ -439,7 +440,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
if (!aggregatedReaction) return null; if (!aggregatedReaction) return null;
aggregatedReaction.forEach((reaction) => { aggregatedReaction.forEach((reaction) => {
if (reaction.type !== 'm.reaction') return; if (reaction.type !== 'm.reaction') return;
addReaction(reaction.key, reaction.count, undefined, false); addReaction(reaction.key, undefined, reaction.count, undefined, false);
}); });
} }
@ -449,13 +450,13 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
Object.keys(reactions).map((key) => ( Object.keys(reactions).map((key) => (
<MessageReaction <MessageReaction
key={key} key={key}
shortcodeToEmoji={shortcodeToEmoji}
reaction={key} reaction={key}
shortcode={reactions[key].shortcode}
count={reactions[key].count} count={reactions[key].count}
users={reactions[key].users} users={reactions[key].users}
isActive={reactions[key].isActive} isActive={reactions[key].isActive}
onClick={() => { onClick={() => {
toggleEmoji(roomId, mEvent.getId(), key, roomTimeline); toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline);
}} }}
/> />
)) ))
@ -607,7 +608,7 @@ function genMediaContent(mE) {
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>; if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
let msgType = mE.getContent()?.msgtype; let msgType = mE.getContent()?.msgtype;
if (mE.getType() === 'm.sticker') msgType = 'm.image'; if (mE.getType() === 'm.sticker') msgType = 'm.sticker';
switch (msgType) { switch (msgType) {
case 'm.file': case 'm.file':
@ -630,6 +631,17 @@ function genMediaContent(mE) {
type={mContent.info?.mimetype} type={mContent.info?.mimetype}
/> />
); );
case 'm.sticker':
return (
<Media.Sticker
name={mContent.body}
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
link={mx.mxcUrlToHttp(mediaMXC)}
file={isEncryptedFile ? mContent.file : null}
type={mContent.info?.mimetype}
/>
);
case 'm.audio': case 'm.audio':
return ( return (
<Media.Audio <Media.Audio

View file

@ -250,7 +250,6 @@
cursor: pointer; cursor: pointer;
& .react-emoji { & .react-emoji {
width: 16px;
height: 16px; height: 16px;
margin: 2px; margin: 2px;
} }

View file

@ -0,0 +1,130 @@
import React, { useReducer, useEffect } from 'react';
import PropTypes from 'prop-types';
import './RoomEmojis.scss';
import initMatrix from '../../../client/initMatrix';
import { suffixRename } from '../../../util/common';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import Text from '../../atoms/text/Text';
import Input from '../../atoms/input/Input';
import Button from '../../atoms/button/Button';
import ImagePack from '../image-pack/ImagePack';
function useRoomPacks(room) {
const mx = initMatrix.matrixClient;
const [, forceUpdate] = useReducer((count) => count + 1, 0);
const packEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
const unUsablePacks = [];
const usablePacks = packEvents.filter((mEvent) => {
if (typeof mEvent.getContent()?.images !== 'object') {
unUsablePacks.push(mEvent);
return false;
}
return true;
});
useEffect(() => {
const handleEvent = (event, state, prevEvent) => {
if (event.getRoomId() !== room.roomId) return;
if (event.getType() !== 'im.ponies.room_emotes') return;
if (!prevEvent?.getContent()?.images || !event.getContent().images) {
forceUpdate();
}
};
mx.on('RoomState.events', handleEvent);
return () => {
mx.removeListener('RoomState.events', handleEvent);
};
}, [room, mx]);
const isStateKeyAvailable = (key) => !room.currentState.getStateEvents('im.ponies.room_emotes', key);
const createPack = async (name) => {
const packContent = {
pack: { display_name: name },
images: {},
};
let stateKey = '';
if (unUsablePacks.length > 0) {
const mEvent = unUsablePacks[0];
stateKey = mEvent.getStateKey();
} else {
stateKey = packContent.pack.display_name.replace(/\s/g, '-');
if (!isStateKeyAvailable(stateKey)) {
stateKey = suffixRename(
stateKey,
isStateKeyAvailable,
);
}
}
await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', packContent, stateKey);
};
const deletePack = async (stateKey) => {
await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', {}, stateKey);
};
return {
usablePacks,
createPack,
deletePack,
};
}
function RoomEmojis({ roomId }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const { usablePacks, createPack, deletePack } = useRoomPacks(room);
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
const handlePackCreate = (e) => {
e.preventDefault();
const { nameInput } = e.target;
const name = nameInput.value.trim();
if (name === '') return;
nameInput.value = '';
createPack(name);
};
return (
<div className="room-emojis">
{ canChange && (
<div className="room-emojis__add-pack">
<MenuHeader>Create Pack</MenuHeader>
<form onSubmit={handlePackCreate}>
<Input name="nameInput" placeholder="Pack Name" required />
<Button variant="primary" type="submit">Create pack</Button>
</form>
</div>
)}
{
usablePacks.length > 0
? usablePacks.reverse().map((mEvent) => (
<ImagePack
key={mEvent.getId()}
roomId={roomId}
stateKey={mEvent.getStateKey()}
handlePackDelete={canChange ? deletePack : undefined}
/>
)) : (
<div className="room-emojis__empty">
<Text>No emoji or sticker pack.</Text>
</div>
)
}
</div>
);
}
RoomEmojis.propTypes = {
roomId: PropTypes.string.isRequired,
};
export default RoomEmojis;

View file

@ -0,0 +1,29 @@
.room-emojis {
.image-pack,
.room-emojis__add-pack,
.room-emojis__empty {
margin: var(--sp-normal) 0;
background-color: var(--bg-surface);
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
overflow: hidden;
& > .context-menu__header:first-child {
margin-top: 2px;
}
}
&__add-pack {
& form {
margin: var(--sp-normal);
display: flex;
gap: var(--sp-normal);
& .input-container {
flex-grow: 1;
}
}
}
&__empty {
padding: var(--sp-extra-loose) var(--sp-normal);
text-align: center;
}
}

View file

@ -70,7 +70,7 @@ const EmojiGroup = React.memo(({ name, groupEmojis }) => {
unicode={`:${emoji.shortcode}:`} unicode={`:${emoji.shortcode}:`}
shortcodes={emoji.shortcode} shortcodes={emoji.shortcode}
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)} src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
data-mx-emoticon data-mx-emoticon={emoji.mxc}
/> />
) )
} }
@ -141,10 +141,13 @@ function EmojiBoard({ onSelect, searchRef }) {
function getEmojiDataFromTarget(target) { function getEmojiDataFromTarget(target) {
const unicode = target.getAttribute('unicode'); const unicode = target.getAttribute('unicode');
const hexcode = target.getAttribute('hexcode'); const hexcode = target.getAttribute('hexcode');
const mxc = target.getAttribute('data-mx-emoticon');
let shortcodes = target.getAttribute('shortcodes'); let shortcodes = target.getAttribute('shortcodes');
if (typeof shortcodes === 'undefined') shortcodes = undefined; if (typeof shortcodes === 'undefined') shortcodes = undefined;
else shortcodes = shortcodes.split(','); else shortcodes = shortcodes.split(',');
return { unicode, hexcode, shortcodes }; return {
unicode, hexcode, shortcodes, mxc,
};
} }
function selectEmoji(e) { function selectEmoji(e) {
@ -202,21 +205,23 @@ function EmojiBoard({ onSelect, searchRef }) {
setAvailableEmojis([]); setAvailableEmojis([]);
return; return;
} }
// Retrieve the packs for the new room
// Remove packs that aren't marked as emoji packs const mx = initMatrix.matrixClient;
// Remove packs without emojis const room = mx.getRoom(selectedRoomId);
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
if (room) {
const packs = getRelevantPacks( const packs = getRelevantPacks(
initMatrix.matrixClient.getRoom(selectedRoomId), room.client,
) [room, ...parentRooms],
.filter((pack) => pack.usage.indexOf('emoticon') !== -1) ).filter((pack) => pack.getEmojis().length !== 0);
.filter((pack) => pack.getEmojis().length !== 0);
// Set an index for each pack so that we know where to jump when the user uses the nav // Set an index for each pack so that we know where to jump when the user uses the nav
for (let i = 0; i < packs.length; i += 1) { for (let i = 0; i < packs.length; i += 1) {
packs[i].packIndex = i; packs[i].packIndex = i;
} }
setAvailableEmojis(packs); setAvailableEmojis(packs);
}
}; };
const onOpen = () => { const onOpen = () => {
@ -260,7 +265,7 @@ function EmojiBoard({ onSelect, searchRef }) {
{ {
availableEmojis.map((pack) => ( availableEmojis.map((pack) => (
<EmojiGroup <EmojiGroup
name={pack.displayName} name={pack.displayName ?? 'Unknown'}
key={pack.packIndex} key={pack.packIndex}
groupEmojis={pack.getEmojis()} groupEmojis={pack.getEmojis()}
className="custom-emoji-group" className="custom-emoji-group"
@ -293,13 +298,14 @@ function EmojiBoard({ onSelect, searchRef }) {
<div className="emoji-board__nav-custom"> <div className="emoji-board__nav-custom">
{ {
availableEmojis.map((pack) => { availableEmojis.map((pack) => {
const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc); const src = initMatrix.matrixClient
.mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc);
return ( return (
<IconButton <IconButton
onClick={() => openGroup(recentOffset + pack.packIndex)} onClick={() => openGroup(recentOffset + pack.packIndex)}
src={src} src={src}
key={pack.packIndex} key={pack.packIndex}
tooltip={pack.displayName} tooltip={pack.displayName ?? 'Unknown'}
tooltipPlacement="right" tooltipPlacement="right"
isImage isImage
/> />

View file

@ -84,6 +84,7 @@
.emoji { .emoji {
width: 32px; width: 32px;
height: 32px; height: 32px;
object-fit: contain;
} }
} }
& > p:last-child { & > p:last-child {
@ -123,6 +124,7 @@
& .emoji { & .emoji {
width: 38px; width: 38px;
height: 38px; height: 38px;
object-fit: contain;
padding: var(--emoji-padding); padding: var(--emoji-padding);
cursor: pointer; cursor: pointer;
&:hover { &:hover {

View file

@ -1,135 +1,224 @@
import { emojis } from './emoji'; import { emojis } from './emoji';
// Custom emoji are stored in one of three places: // https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
// - User emojis, which are stored in account data
// - Room emojis, which are stored in state events in a room
// - Emoji packs, which are rooms of emojis referenced in the account data or in a room's
// cannonical space
//
// Emojis and packs referenced from within a user's account data should be available
// globally, while emojis and packs in rooms and spaces should only be available within
// those spaces and rooms
class ImagePack { class ImagePack {
// Convert a raw image pack into a more maliable format static parsePack(eventId, packContent) {
// if (!eventId || typeof packContent?.images !== 'object') {
// Takes an image pack as per MSC 2545 (e.g. as in the Matrix spec), and converts it to a
// format used here, while filling in defaults.
//
// The room argument is the room the pack exists in, which is used as a fallback for
// missing properties
//
// Returns `null` if the rawPack is not a properly formatted image pack, although there
// is still a fair amount of tolerance for malformed packs.
static parsePack(rawPack, room) {
if (typeof rawPack.images === 'undefined') {
return null; return null;
} }
const pack = rawPack.pack ?? {}; return new ImagePack(eventId, packContent);
}
const displayName = pack.display_name ?? (room ? room.name : undefined); constructor(eventId, content) {
const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined); this.id = eventId;
const usage = pack.usage ?? ['emoticon', 'sticker']; this.content = JSON.parse(JSON.stringify(content));
const { attribution } = pack;
const images = Object.entries(rawPack.images).flatMap((e) => { this.applyPack(content);
const data = e[1]; this.applyImages(content);
const shortcode = e[0]; }
applyPack(content) {
const pack = content.pack ?? {};
this.displayName = pack.display_name;
this.avatarUrl = pack.avatar_url;
this.usage = pack.usage ?? ['emoticon', 'sticker'];
this.attribution = pack.attribution;
}
applyImages(content) {
this.images = new Map();
this.emoticons = [];
this.stickers = [];
Object.entries(content.images).forEach(([shortcode, data]) => {
const mxc = data.url; const mxc = data.url;
const body = data.body ?? shortcode; const body = data.body ?? shortcode;
const usage = data.usage ?? this.usage;
const { info } = data; const { info } = data;
const usage_ = data.usage ?? usage;
if (mxc) { if (!mxc) return;
return [{ const image = {
shortcode, mxc, body, info, usage: usage_, shortcode, mxc, body, usage, info,
}]; };
this.images.set(shortcode, image);
if (usage.includes('emoticon')) {
this.emoticons.push(image);
}
if (usage.includes('sticker')) {
this.stickers.push(image);
} }
return [];
}); });
return new ImagePack(displayName, avatar, usage, attribution, images);
} }
constructor(displayName, avatar, usage, attribution, images) { getImages() {
this.displayName = displayName; return this.images;
this.avatar = avatar;
this.usage = usage;
this.attribution = attribution;
this.images = images;
} }
// Produce a list of emoji in this image pack
getEmojis() { getEmojis() {
return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1); return this.emoticons;
} }
// Produce a list of stickers in this image pack
getStickers() { getStickers() {
return this.images.filter((i) => i.usage.indexOf('sticker') !== -1); return this.stickers;
}
getContent() {
return this.content;
}
_updatePackProperty(property, value) {
if (this.content.pack === undefined) {
this.content.pack = {};
}
this.content.pack[property] = value;
this.applyPack(this.content);
}
setAvatarUrl(avatarUrl) {
this._updatePackProperty('avatar_url', avatarUrl);
}
setDisplayName(displayName) {
this._updatePackProperty('display_name', displayName);
}
setAttribution(attribution) {
this._updatePackProperty('attribution', attribution);
}
setUsage(usage) {
this._updatePackProperty('usage', usage);
}
addImage(key, imgContent) {
this.content.images = {
[key]: imgContent,
...this.content.images,
};
this.applyImages(this.content);
}
removeImage(key) {
if (this.content.images[key] === undefined) return;
delete this.content.images[key];
this.applyImages(this.content);
}
updateImageKey(key, newKey) {
if (this.content.images[key] === undefined) return;
const copyImages = {};
Object.keys(this.content.images).forEach((imgKey) => {
copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
});
this.content.images = copyImages;
this.applyImages(this.content);
}
_updateImageProperty(key, property, value) {
if (this.content.images[key] === undefined) return;
this.content.images[key][property] = value;
this.applyImages(this.content);
}
setImageUrl(key, url) {
this._updateImageProperty(key, 'url', url);
}
setImageBody(key, body) {
this._updateImageProperty(key, 'body', body);
}
setImageInfo(key, info) {
this._updateImageProperty(key, 'info', info);
}
setImageUsage(key, usage) {
this._updateImageProperty(key, 'usage', usage);
} }
} }
// Retrieve a list of user emojis function getGlobalImagePacks(mx) {
// const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal if (typeof globalContent !== 'object') return [];
// image pack.
// const { rooms } = globalContent;
// Accepts a reference to a matrix client as the only argument if (typeof rooms !== 'object') return [];
const roomIds = Object.keys(rooms);
const packs = roomIds.flatMap((roomId) => {
if (typeof rooms[roomId] !== 'object') return [];
const room = mx.getRoom(roomId);
if (!room) return [];
const stateKeys = Object.keys(rooms[roomId]);
return stateKeys.map((stateKey) => {
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
if (pack) {
pack.displayName ??= room.name;
pack.avatarUrl ??= room.getMxcAvatarUrl();
}
return pack;
}).filter((pack) => pack !== null);
});
return packs;
}
function getUserImagePack(mx) { function getUserImagePack(mx) {
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes'); const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
if (!accountDataEmoji) { if (!accountDataEmoji) {
return null; return null;
} }
const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content); const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
if (userImagePack) userImagePack.displayName ??= 'Your Emoji'; userImagePack.displayName ??= 'Personal Emoji';
return userImagePack; return userImagePack;
} }
// Produces a list of all of the emoji packs in a room function getRoomImagePacks(room) {
// const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
// Returns a list of `ImagePack`s. This does not include packs in spaces that contain
// this room.
function getPacksInRoom(room) {
const packs = room.currentState.getStateEvents('im.ponies.room_emotes');
return packs return dataEvents
.map((p) => ImagePack.parsePack(p.event.content, room)) .map((data) => {
.filter((p) => p !== null); const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
if (pack) {
pack.displayName ??= room.name;
pack.avatarUrl ??= room.getMxcAvatarUrl();
}
return pack;
})
.filter((pack) => pack !== null);
} }
// Produce a list of all image packs which should be shown for a given room /**
// * @param {MatrixClient} mx Provide if you want to include user personal/global pack
// This includes packs in that room, the user's personal images, and will eventually * @param {Room[]} rooms Provide rooms if you want to include rooms pack
// include the user's enabled global image packs and space-level packs. * @returns {ImagePack[]} packs
// */
// This differs from getPacksInRoom, as the former only returns packs that are directly in function getRelevantPacks(mx, rooms) {
// a room, whereas this function returns all packs which should be shown to the user while const userPack = mx ? getUserImagePack(mx) : [];
// they are in this room. const globalPacks = mx ? getGlobalImagePacks(mx) : [];
// const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
// Packs will be returned in the order that shortcode conflicts should be resolved, with const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
// higher priority packs coming first.
function getRelevantPacks(room) {
return [].concat( return [].concat(
getUserImagePack(room.client) ?? [], userPack ?? [],
getPacksInRoom(room), globalPacks,
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
); );
} }
// Returns all user+room emojis and all standard unicode emojis function getShortcodeToEmoji(mx, rooms) {
//
// Accepts a reference to a matrix client as the only argument
//
// Result is a map from shortcode to the corresponding emoji. If two emoji share a
// shortcode, only one will be presented, with priority given to custom emoji.
//
// Will eventually be expanded to include all emojis revelant to a room and the user
function getShortcodeToEmoji(room) {
const allEmoji = new Map(); const allEmoji = new Map();
emojis.forEach((emoji) => { emojis.forEach((emoji) => {
if (emoji.shortcodes.constructor.name === 'Array') { if (Array.isArray(emoji.shortcodes)) {
emoji.shortcodes.forEach((shortcode) => { emoji.shortcodes.forEach((shortcode) => {
allEmoji.set(shortcode, emoji); allEmoji.set(shortcode, emoji);
}); });
@ -138,7 +227,7 @@ function getShortcodeToEmoji(room) {
} }
}); });
getRelevantPacks(room).reverse() getRelevantPacks(mx, rooms)
.flatMap((pack) => pack.getEmojis()) .flatMap((pack) => pack.getEmojis())
.forEach((emoji) => { .forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji); allEmoji.set(emoji.shortcode, emoji);
@ -150,7 +239,7 @@ function getShortcodeToEmoji(room) {
function getShortcodeToCustomEmoji(room) { function getShortcodeToCustomEmoji(room) {
const allEmoji = new Map(); const allEmoji = new Map();
getRelevantPacks(room).reverse() getRelevantPacks(room.client, [room])
.flatMap((pack) => pack.getEmojis()) .flatMap((pack) => pack.getEmojis())
.forEach((emoji) => { .forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji); allEmoji.set(emoji.shortcode, emoji);
@ -159,27 +248,20 @@ function getShortcodeToCustomEmoji(room) {
return allEmoji; return allEmoji;
} }
// Produces a special list of emoji specifically for auto-completion function getEmojiForCompletion(mx, rooms) {
//
// This list contains each emoji once, with all emoji being deduplicated by shortcode.
// However, the order of the standard emoji will have been preserved, and alternate
// shortcodes for the standard emoji will not be considered.
//
// Standard emoji are guaranteed to be earlier in the list than custom emoji
function getEmojiForCompletion(room) {
const allEmoji = new Map(); const allEmoji = new Map();
getRelevantPacks(room).reverse() getRelevantPacks(mx, rooms)
.flatMap((pack) => pack.getEmojis()) .flatMap((pack) => pack.getEmojis())
.forEach((emoji) => { .forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji); allEmoji.set(emoji.shortcode, emoji);
}); });
return emojis.filter((e) => !allEmoji.has(e.shortcode)) return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
.concat(Array.from(allEmoji.values()));
} }
export { export {
getUserImagePack, ImagePack,
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
getShortcodeToEmoji, getShortcodeToCustomEmoji, getShortcodeToEmoji, getShortcodeToCustomEmoji,
getRelevantPacks, getEmojiForCompletion, getRelevantPacks, getEmojiForCompletion,
}; };

View file

@ -25,9 +25,11 @@ import RoomHistoryVisibility from '../../molecules/room-history-visibility/RoomH
import RoomEncryption from '../../molecules/room-encryption/RoomEncryption'; import RoomEncryption from '../../molecules/room-encryption/RoomEncryption';
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions'; import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
import RoomMembers from '../../molecules/room-members/RoomMembers'; import RoomMembers from '../../molecules/room-members/RoomMembers';
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg'; import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg'; import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg'; import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg'; import LockIC from '../../../../public/res/ic/outlined/lock.svg';
@ -42,6 +44,7 @@ const tabText = {
GENERAL: 'General', GENERAL: 'General',
SEARCH: 'Search', SEARCH: 'Search',
MEMBERS: 'Members', MEMBERS: 'Members',
EMOJIS: 'Emojis',
PERMISSIONS: 'Permissions', PERMISSIONS: 'Permissions',
SECURITY: 'Security', SECURITY: 'Security',
}; };
@ -58,6 +61,10 @@ const tabItems = [{
iconSrc: UserIC, iconSrc: UserIC,
text: tabText.MEMBERS, text: tabText.MEMBERS,
disabled: false, disabled: false,
}, {
iconSrc: EmojiIC,
text: tabText.EMOJIS,
disabled: false,
}, { }, {
iconSrc: ShieldUserIC, iconSrc: ShieldUserIC,
text: tabText.PERMISSIONS, text: tabText.PERMISSIONS,
@ -197,6 +204,7 @@ function RoomSettings({ roomId }) {
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />} {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />} {selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />} {selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />} {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />} {selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
</div> </div>
@ -210,7 +218,5 @@ RoomSettings.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
}; };
export { export default RoomSettings;
RoomSettings as default, export { tabText };
tabText,
};

View file

@ -21,7 +21,7 @@ import AsyncSearch from '../../../util/AsyncSearch';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import FollowingMembers from '../../molecules/following-members/FollowingMembers'; import FollowingMembers from '../../molecules/following-members/FollowingMembers';
import { addRecentEmoji } from '../emoji-board/recent'; import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
const commands = [{ const commands = [{
name: 'markdown', name: 'markdown',
@ -213,9 +213,15 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
setCmd({ prefix, suggestions: commands }); setCmd({ prefix, suggestions: commands });
}, },
':': () => { ':': () => {
const emojis = getEmojiForCompletion(mx.getRoom(roomId)); const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
const recentEmoji = getRecentEmojis(20);
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 }); asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
setCmd({ prefix, suggestions: emojis.slice(26, 46) }); setCmd({
prefix,
suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
});
}, },
'@': () => { '@': () => {
const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({ const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({
@ -247,7 +253,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
} }
if (myCmd.prefix === '@') { if (myCmd.prefix === '@') {
viewEvent.emit('cmd_fired', { viewEvent.emit('cmd_fired', {
replace: myCmd.result.name, replace: `@${myCmd.result.userId}`,
}); });
} }
deactivateCmd(); deactivateCmd();

View file

@ -8,7 +8,7 @@ import TextareaAutosize from 'react-autosize-textarea';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings'; import settings from '../../../client/state/settings';
import { openEmojiBoard } from '../../../client/action/navigation'; import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { bytesToSize, getEventCords } from '../../../util/common'; import { bytesToSize, getEventCords } from '../../../util/common';
import { getUsername } from '../../../util/matrixUtil'; import { getUsername } from '../../../util/matrixUtil';
@ -20,9 +20,12 @@ import IconButton from '../../atoms/button/IconButton';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import { MessageReply } from '../../molecules/message/Message'; import { MessageReply } from '../../molecules/message/Message';
import StickerBoard from '../sticker-board/StickerBoard';
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg'; import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SendIC from '../../../../public/res/ic/outlined/send.svg'; import SendIC from '../../../../public/res/ic/outlined/send.svg';
import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg'; import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg'; import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg'; import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
@ -128,7 +131,11 @@ function RoomViewInput({
} }
function firedCmd(cmdData) { function firedCmd(cmdData) {
const msg = textAreaRef.current.value; const msg = textAreaRef.current.value;
textAreaRef.current.value = replaceCmdWith(msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : ''); textAreaRef.current.value = replaceCmdWith(
msg,
cmdCursorPos,
typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
);
deactivateCmd(); deactivateCmd();
} }
@ -199,6 +206,33 @@ function RoomViewInput({
if (replyTo !== null) setReplyTo(null); if (replyTo !== null) setReplyTo(null);
}; };
const handleSendSticker = async (data) => {
const { mxc: url, body, httpUrl } = data;
const info = {};
const img = new Image();
img.src = httpUrl;
try {
const res = await fetch(httpUrl);
const blob = await res.blob();
info.w = img.width;
info.h = img.height;
info.mimetype = blob.type;
info.size = blob.size;
info.thumbnail_info = { ...info };
info.thumbnail_url = url;
} catch {
// send sticker without info
}
mx.sendEvent(roomId, 'm.sticker', {
body,
url,
info,
});
};
function processTyping(msg) { function processTyping(msg) {
const isEmptyMsg = msg === ''; const isEmptyMsg = msg === '';
@ -338,6 +372,29 @@ function RoomViewInput({
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />} {isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
</div> </div>
<div ref={rightOptionsRef} className="room-input__option-container"> <div ref={rightOptionsRef} className="room-input__option-container">
<IconButton
onClick={(e) => {
openReusableContextMenu(
'top',
(() => {
const cords = getEventCords(e);
cords.y -= 20;
return cords;
})(),
(closeMenu) => (
<StickerBoard
roomId={roomId}
onSelect={(data) => {
handleSendSticker(data);
closeMenu();
}}
/>
),
);
}}
tooltip="Sticker"
src={StickerIC}
/>
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {
const cords = getEventCords(e); const cords = getEventCords(e);

View file

@ -24,6 +24,7 @@ import PopupWindow from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile'; import SettingTile from '../../molecules/setting-tile/SettingTile';
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys'; import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys'; import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack';
import ProfileEditor from '../profile-editor/ProfileEditor'; import ProfileEditor from '../profile-editor/ProfileEditor';
import CrossSigning from './CrossSigning'; import CrossSigning from './CrossSigning';
@ -31,6 +32,7 @@ import KeyBackup from './KeyBackup';
import DeviceManage from './DeviceManage'; import DeviceManage from './DeviceManage';
import SunIC from '../../../../public/res/ic/outlined/sun.svg'; import SunIC from '../../../../public/res/ic/outlined/sun.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg'; import LockIC from '../../../../public/res/ic/outlined/lock.svg';
import BellIC from '../../../../public/res/ic/outlined/bell.svg'; import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg'; import InfoIC from '../../../../public/res/ic/outlined/info.svg';
@ -169,6 +171,15 @@ function NotificationsSection() {
); );
} }
function EmojiSection() {
return (
<>
<div className="settings-emoji__card"><ImagePackUser /></div>
<div className="settings-emoji__card"><ImagePackGlobal /></div>
</>
);
}
function SecuritySection() { function SecuritySection() {
return ( return (
<div className="settings-security"> <div className="settings-security">
@ -250,6 +261,7 @@ function AboutSection() {
export const tabText = { export const tabText = {
APPEARANCE: 'Appearance', APPEARANCE: 'Appearance',
NOTIFICATIONS: 'Notifications', NOTIFICATIONS: 'Notifications',
EMOJI: 'Emoji',
SECURITY: 'Security', SECURITY: 'Security',
ABOUT: 'About', ABOUT: 'About',
}; };
@ -263,6 +275,11 @@ const tabItems = [{
iconSrc: BellIC, iconSrc: BellIC,
disabled: false, disabled: false,
render: () => <NotificationsSection />, render: () => <NotificationsSection />,
}, {
text: tabText.EMOJI,
iconSrc: EmojiIC,
disabled: false,
render: () => <EmojiSection />,
}, { }, {
text: tabText.SECURITY, text: tabText.SECURITY,
iconSrc: LockIC, iconSrc: LockIC,

View file

@ -40,7 +40,8 @@
.settings-notifications, .settings-notifications,
.settings-security__card, .settings-security__card,
.settings-security .device-manage, .settings-security .device-manage,
.settings-about__card { .settings-about__card,
.settings-emoji__card {
@extend .settings-window__card; @extend .settings-window__card;
} }

View file

@ -25,6 +25,7 @@ import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
import RoomAliases from '../../molecules/room-aliases/RoomAliases'; import RoomAliases from '../../molecules/room-aliases/RoomAliases';
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions'; import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
import RoomMembers from '../../molecules/room-members/RoomMembers'; import RoomMembers from '../../molecules/room-members/RoomMembers';
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
@ -35,6 +36,7 @@ import PinIC from '../../../../public/res/ic/outlined/pin.svg';
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg'; import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
import CategoryIC from '../../../../public/res/ic/outlined/category.svg'; import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg'; import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog'; import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import { useForceUpdate } from '../../hooks/useForceUpdate'; import { useForceUpdate } from '../../hooks/useForceUpdate';
@ -42,6 +44,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate';
const tabText = { const tabText = {
GENERAL: 'General', GENERAL: 'General',
MEMBERS: 'Members', MEMBERS: 'Members',
EMOJIS: 'Emojis',
PERMISSIONS: 'Permissions', PERMISSIONS: 'Permissions',
}; };
@ -53,6 +56,10 @@ const tabItems = [{
iconSrc: UserIC, iconSrc: UserIC,
text: tabText.MEMBERS, text: tabText.MEMBERS,
disabled: false, disabled: false,
}, {
iconSrc: EmojiIC,
text: tabText.EMOJIS,
disabled: false,
}, { }, {
iconSrc: ShieldUserIC, iconSrc: ShieldUserIC,
text: tabText.PERMISSIONS, text: tabText.PERMISSIONS,
@ -178,6 +185,7 @@ function SpaceSettings() {
<div className="space-settings__cards-wrapper"> <div className="space-settings__cards-wrapper">
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />} {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />} {selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />} {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
</div> </div>
</div> </div>

View file

@ -0,0 +1,88 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import React from 'react';
import PropTypes from 'prop-types';
import './StickerBoard.scss';
import initMatrix from '../../../client/initMatrix';
import { getRelevantPacks } from '../emoji-board/custom-emoji';
import Text from '../../atoms/text/Text';
import ScrollView from '../../atoms/scroll/ScrollView';
function StickerBoard({ roomId, onSelect }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
const packs = getRelevantPacks(
mx,
[room, ...parentRooms],
).filter((pack) => pack.getStickers().length !== 0);
function isTargetNotSticker(target) {
return target.classList.contains('sticker-board__sticker') === false;
}
function getStickerData(target) {
const mxc = target.getAttribute('data-mx-sticker');
const body = target.getAttribute('title');
const httpUrl = target.getAttribute('src');
return { mxc, body, httpUrl };
}
const handleOnSelect = (e) => {
if (isTargetNotSticker(e.target)) return;
const stickerData = getStickerData(e.target);
onSelect(stickerData);
};
const renderPack = (pack) => (
<div className="sticker-board__pack" key={pack.id}>
<Text className="sticker-board__pack-header" variant="b2" weight="bold">{pack.displayName ?? 'Unknown'}</Text>
<div className="sticker-board__pack-items">
{pack.getStickers().map((sticker) => (
<img
key={sticker.shortcode}
className="sticker-board__sticker"
src={mx.mxcUrlToHttp(sticker.mxc)}
alt={sticker.shortcode}
title={sticker.body ?? sticker.shortcode}
data-mx-sticker={sticker.mxc}
/>
))}
</div>
</div>
);
return (
<div className="sticker-board">
<div className="sticker-board__container">
<ScrollView autoHide>
<div
onClick={handleOnSelect}
className="sticker-board__content"
>
{
packs.length > 0
? packs.map(renderPack)
: (
<div className="sticker-board__empty">
<Text>There is no sticker pack.</Text>
</div>
)
}
</div>
</ScrollView>
</div>
<div />
</div>
);
}
StickerBoard.propTypes = {
roomId: PropTypes.string.isRequired,
onSelect: PropTypes.func.isRequired,
};
export default StickerBoard;

View file

@ -0,0 +1,60 @@
@use '../../partials/dir';
.sticker-board {
--sticker-board-height: 390px;
--sticker-board-width: 286px;
display: flex;
height: var(--sticker-board-height);
&__container {
flex-grow: 1;
min-width: 0;
width: var(--sticker-board-width);
display: flex;
}
&__content {
min-height: 100%;
}
&__pack {
margin-bottom: var(--sp-normal);
position: relative;
&-header {
position: sticky;
top: 0;
z-index: 99;
background-color: var(--bg-surface);
@include dir.side(margin, var(--sp-extra-tight), 0);
padding: var(--sp-extra-tight) var(--sp-ultra-tight);
text-transform: uppercase;
box-shadow: 0 -4px 0 0 var(--bg-surface);
border-bottom: 1px solid var(--bg-surface-border);
}
&-items {
margin: var(--sp-tight);
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
display: flex;
flex-wrap: wrap;
gap: var(--sp-normal) var(--sp-tight);
img {
width: 76px;
height: 76px;
object-fit: contain;
cursor: pointer;
}
}
}
&__empty {
width: 100%;
height: var(--sticker-board-height);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
}

View file

@ -11,17 +11,18 @@ async function redactEvent(roomId, eventId, reason) {
} }
} }
async function sendReaction(roomId, toEventId, reaction) { async function sendReaction(roomId, toEventId, reaction, shortcode) {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const content = {
try {
await mx.sendEvent(roomId, 'm.reaction', {
'm.relates_to': { 'm.relates_to': {
event_id: toEventId, event_id: toEventId,
key: reaction, key: reaction,
rel_type: 'm.annotation', rel_type: 'm.annotation',
}, },
}); };
if (typeof shortcode === 'string') content.shortcode = shortcode;
try {
await mx.sendEvent(roomId, 'm.reaction', content);
} catch (e) { } catch (e) {
throw new Error(e); throw new Error(e);
} }

View file

@ -67,7 +67,7 @@ class InitMatrix extends EventEmitter {
if (prevState === null) { if (prevState === null) {
this.roomList = new RoomList(this.matrixClient); this.roomList = new RoomList(this.matrixClient);
this.accountData = new AccountData(this.roomList); this.accountData = new AccountData(this.roomList);
this.roomsInput = new RoomsInput(this.matrixClient); this.roomsInput = new RoomsInput(this.matrixClient, this.roomList);
this.notifications = new Notifications(this.roomList); this.notifications = new Notifications(this.roomList);
this.emit('init_loading_finished'); this.emit('init_loading_finished');
} }

View file

@ -5,21 +5,10 @@ import encrypt from 'browser-encrypt-attachment';
import { math } from 'micromark-extension-math'; import { math } from 'micromark-extension-math';
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji'; import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown'; import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
import { getImageDimension } from '../../util/common';
import cons from './cons'; import cons from './cons';
import settings from './settings'; import settings from './settings';
function getImageDimension(file) {
return new Promise((resolve) => {
const img = new Image();
img.onload = async () => {
resolve({
w: img.width,
h: img.height,
});
};
img.src = URL.createObjectURL(file);
});
}
function loadVideo(videoFile) { function loadVideo(videoFile) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const video = document.createElement('video'); const video = document.createElement('video');
@ -120,14 +109,13 @@ function bindReplyToContent(roomId, reply, content) {
return newContent; return newContent;
} }
// Apply formatting to a plain text message function formatAndEmojifyText(mx, roomList, roomId, text) {
// const room = mx.getRoom(roomId);
// This includes inserting any custom emoji that might be relevant, and (only if the const { userIdsToDisplayNames } = room.currentState;
// user has enabled it in their settings) formatting the message using markdown. const parentIds = roomList.getAllParentSpaces(roomId);
function formatAndEmojifyText(room, text) { const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
const allEmoji = getShortcodeToEmoji(room); const allEmoji = getShortcodeToEmoji(mx, [room, ...parentRooms]);
// Start by applying markdown formatting (if relevant)
let formattedText; let formattedText;
if (settings.isMarkdown) { if (settings.isMarkdown) {
formattedText = getFormattedBody(text); formattedText = getFormattedBody(text);
@ -135,17 +123,25 @@ function formatAndEmojifyText(room, text) {
formattedText = text; formattedText = text;
} }
// Check to see if there are any :shortcode-style-tags: in the message const MXID_REGEX = /\B@\S+:\S+\.\S+[^.,:;?!\s]/g;
Array.from(formattedText.matchAll(/\B:([\w-]+):\B/g)) Array.from(formattedText.matchAll(MXID_REGEX))
// Then filter to only the ones corresponding to a valid emoji .filter((mxidMatch) => userIdsToDisplayNames[mxidMatch[0]])
.filter((match) => allEmoji.has(match[1]))
// Reversing the array ensures that indices are preserved as we start replacing
.reverse() .reverse()
// Replace each :shortcode: with an <img/> tag .forEach((mxidMatch) => {
const tag = `<a href="https://matrix.to/#/${mxidMatch[0]}">${userIdsToDisplayNames[mxidMatch[0]]}</a>`;
formattedText = formattedText.substr(0, mxidMatch.index)
+ tag
+ formattedText.substr(mxidMatch.index + mxidMatch[0].length);
});
const SHORTCODE_REGEX = /\B:([\w-]+):\B/g;
Array.from(formattedText.matchAll(SHORTCODE_REGEX))
.filter((shortcodeMatch) => allEmoji.has(shortcodeMatch[1]))
.reverse() /* Reversing the array ensures that indices are preserved as we start replacing */
.forEach((shortcodeMatch) => { .forEach((shortcodeMatch) => {
const emoji = allEmoji.get(shortcodeMatch[1]); const emoji = allEmoji.get(shortcodeMatch[1]);
// Render the tag that will replace the shortcode
let tag; let tag;
if (emoji.mxc) { if (emoji.mxc) {
tag = `<img data-mx-emoticon="" src="${ tag = `<img data-mx-emoticon="" src="${
@ -159,7 +155,6 @@ function formatAndEmojifyText(room, text) {
tag = emoji.unicode; tag = emoji.unicode;
} }
// Splice the tag into the text
formattedText = formattedText.substr(0, shortcodeMatch.index) formattedText = formattedText.substr(0, shortcodeMatch.index)
+ tag + tag
+ formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length); + formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length);
@ -169,10 +164,11 @@ function formatAndEmojifyText(room, text) {
} }
class RoomsInput extends EventEmitter { class RoomsInput extends EventEmitter {
constructor(mx) { constructor(mx, roomList) {
super(); super();
this.matrixClient = mx; this.matrixClient = mx;
this.roomList = roomList;
this.roomIdToInput = new Map(); this.roomIdToInput = new Map();
} }
@ -273,7 +269,9 @@ class RoomsInput extends EventEmitter {
// Apply formatting if relevant // Apply formatting if relevant
const formattedBody = formatAndEmojifyText( const formattedBody = formatAndEmojifyText(
this.matrixClient.getRoom(roomId), this.matrixClient,
this.roomList,
roomId,
input.message, input.message,
); );
if (formattedBody !== input.message) { if (formattedBody !== input.message) {
@ -412,7 +410,9 @@ class RoomsInput extends EventEmitter {
// Apply formatting if relevant // Apply formatting if relevant
const formattedBody = formatAndEmojifyText( const formattedBody = formatAndEmojifyText(
this.matrixClient.getRoom(roomId), this.matrixClient,
this.roomList,
roomId,
editedBody, editedBody,
); );
if (formattedBody !== editedBody) { if (formattedBody !== editedBody) {

View file

@ -132,3 +132,62 @@ export function copyToClipboard(text) {
copyInput.remove(); copyInput.remove();
} }
} }
export function suffixRename(name, validator) {
let suffix = 2;
let newName = name;
do {
newName = name + suffix;
suffix += 1;
} while (validator(newName));
return newName;
}
export function getImageDimension(file) {
return new Promise((resolve) => {
const img = new Image();
img.onload = async () => {
resolve({
w: img.width,
h: img.height,
});
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
}
export function scaleDownImage(imageFile, width, height) {
return new Promise((resolve) => {
const imgURL = URL.createObjectURL(imageFile);
const img = new Image();
img.onload = () => {
let newWidth = img.width;
let newHeight = img.height;
if (newHeight > height) {
newWidth = Math.floor(newWidth * (height / newHeight));
newHeight = height;
}
if (newWidth > width) {
newHeight = Math.floor(newHeight * (width / newWidth));
newWidth = width;
}
const canvas = document.createElement('canvas');
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, newWidth, newHeight);
canvas.toBlob((thumbnail) => {
URL.revokeObjectURL(imgURL);
resolve(thumbnail);
}, imageFile.type);
};
img.src = imgURL;
});
}