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:
parent
5e527e434a
commit
edace32213
33 changed files with 1781 additions and 203 deletions
4
public/res/ic/outlined/sticker.svg
Normal file
4
public/res/ic/outlined/sticker.svg
Normal 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 |
|
@ -26,12 +26,12 @@
|
|||
&--icon {
|
||||
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
||||
|
||||
}
|
||||
.ic-raw {
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin color($textColor, $iconColor) {
|
||||
.text {
|
||||
|
|
469
src/app/molecules/image-pack/ImagePack.jsx
Normal file
469
src/app/molecules/image-pack/ImagePack.jsx
Normal 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 };
|
47
src/app/molecules/image-pack/ImagePack.scss
Normal file
47
src/app/molecules/image-pack/ImagePack.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
76
src/app/molecules/image-pack/ImagePackItem.jsx
Normal file
76
src/app/molecules/image-pack/ImagePackItem.jsx
Normal 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;
|
43
src/app/molecules/image-pack/ImagePackItem.scss
Normal file
43
src/app/molecules/image-pack/ImagePackItem.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
125
src/app/molecules/image-pack/ImagePackProfile.jsx
Normal file
125
src/app/molecules/image-pack/ImagePackProfile.jsx
Normal 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;
|
37
src/app/molecules/image-pack/ImagePackProfile.scss
Normal file
37
src/app/molecules/image-pack/ImagePackProfile.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
73
src/app/molecules/image-pack/ImagePackUpload.jsx
Normal file
73
src/app/molecules/image-pack/ImagePackUpload.jsx
Normal 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;
|
43
src/app/molecules/image-pack/ImagePackUpload.scss
Normal file
43
src/app/molecules/image-pack/ImagePackUpload.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
41
src/app/molecules/image-pack/ImagePackUsageSelector.jsx
Normal file
41
src/app/molecules/image-pack/ImagePackUsageSelector.jsx
Normal 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;
|
|
@ -7,9 +7,13 @@ import initMatrix from '../../../client/initMatrix';
|
|||
import Text from '../../atoms/text/Text';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
|
||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
||||
|
||||
function ImageUpload({
|
||||
text, bgColor, imageSrc, onUpload, onRequestRemove,
|
||||
size,
|
||||
}) {
|
||||
const [uploadPromise, setUploadPromise] = useState(null);
|
||||
const uploadImageRef = useRef(null);
|
||||
|
@ -50,10 +54,14 @@ function ImageUpload({
|
|||
imageSrc={imageSrc}
|
||||
text={text}
|
||||
bgColor={bgColor}
|
||||
size="large"
|
||||
size={size}
|
||||
/>
|
||||
<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" />}
|
||||
</div>
|
||||
</button>
|
||||
|
@ -75,6 +83,7 @@ ImageUpload.defaultProps = {
|
|||
text: null,
|
||||
bgColor: 'transparent',
|
||||
imageSrc: null,
|
||||
size: 'large',
|
||||
};
|
||||
|
||||
ImageUpload.propTypes = {
|
||||
|
@ -83,6 +92,7 @@ ImageUpload.propTypes = {
|
|||
imageSrc: PropTypes.string,
|
||||
onUpload: PropTypes.func.isRequired,
|
||||
onRequestRemove: PropTypes.func.isRequired,
|
||||
size: PropTypes.oneOf(['large', 'normal']),
|
||||
};
|
||||
|
||||
export default ImageUpload;
|
||||
|
|
|
@ -69,9 +69,8 @@ async function getUrl(link, type, decryptData) {
|
|||
}
|
||||
}
|
||||
|
||||
function getNativeHeight(width, height) {
|
||||
const MEDIA_MAX_WIDTH = 296;
|
||||
const scale = MEDIA_MAX_WIDTH / width;
|
||||
function getNativeHeight(width, height, maxWidth = 296) {
|
||||
const scale = maxWidth / width;
|
||||
return scale * height;
|
||||
}
|
||||
|
||||
|
@ -196,6 +195,45 @@ Image.propTypes = {
|
|||
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({
|
||||
name, link, type, file,
|
||||
}) {
|
||||
|
@ -315,5 +353,5 @@ Video.propTypes = {
|
|||
};
|
||||
|
||||
export {
|
||||
File, Image, Audio, Video,
|
||||
File, Image, Sticker, Audio, Video,
|
||||
};
|
||||
|
|
|
@ -42,6 +42,15 @@
|
|||
background-size: cover;
|
||||
}
|
||||
|
||||
.sticker-container {
|
||||
display: inline-flex;
|
||||
max-width: 128px;
|
||||
width: 100%;
|
||||
& img {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.image-container {
|
||||
& img {
|
||||
max-width: unset !important;
|
||||
|
|
|
@ -5,7 +5,6 @@ import React, {
|
|||
import PropTypes from 'prop-types';
|
||||
import './Message.scss';
|
||||
|
||||
import { getShortcodeToCustomEmoji } from '../../organisms/emoji-board/custom-emoji';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
|
@ -322,7 +321,7 @@ function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
|
|||
return rEvent;
|
||||
}
|
||||
|
||||
function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
|
||||
function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) {
|
||||
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
|
||||
if (myAlreadyReactEvent) {
|
||||
const rId = myAlreadyReactEvent.getId();
|
||||
|
@ -330,17 +329,17 @@ function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
|
|||
redactEvent(roomId, rId);
|
||||
return;
|
||||
}
|
||||
sendReaction(roomId, eventId, emojiKey);
|
||||
sendReaction(roomId, eventId, emojiKey, shortcode);
|
||||
}
|
||||
|
||||
function pickEmoji(e, roomId, eventId, roomTimeline) {
|
||||
openEmojiBoard(getEventCords(e), (emoji) => {
|
||||
toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
|
||||
toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline);
|
||||
e.target.click();
|
||||
});
|
||||
}
|
||||
|
||||
function genReactionMsg(userIds, reaction) {
|
||||
function genReactionMsg(userIds, reaction, shortcode) {
|
||||
return (
|
||||
<>
|
||||
{userIds.map((userId, index) => (
|
||||
|
@ -354,24 +353,22 @@ function genReactionMsg(userIds, reaction) {
|
|||
</React.Fragment>
|
||||
))}
|
||||
<span style={{ opacity: '.6' }}>{' reacted with '}</span>
|
||||
{twemojify(reaction, { className: 'react-emoji' })}
|
||||
{twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageReaction({
|
||||
shortcodeToEmoji, reaction, count, users, isActive, onClick,
|
||||
reaction, shortcode, count, users, isActive, onClick,
|
||||
}) {
|
||||
const customEmojiMatch = reaction.match(/^:(\S+):$/);
|
||||
let customEmojiUrl = null;
|
||||
if (customEmojiMatch) {
|
||||
const customEmoji = shortcodeToEmoji.get(customEmojiMatch[1]);
|
||||
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(customEmoji?.mxc);
|
||||
if (reaction.match(/^mxc:\/\/\S+$/)) {
|
||||
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
|
||||
}
|
||||
return (
|
||||
<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
|
||||
onClick={onClick}
|
||||
|
@ -380,7 +377,7 @@ function MessageReaction({
|
|||
>
|
||||
{
|
||||
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' })
|
||||
}
|
||||
<Text variant="b3" className="msg__reaction-count">{count}</Text>
|
||||
|
@ -388,9 +385,12 @@ function MessageReaction({
|
|||
</Tooltip>
|
||||
);
|
||||
}
|
||||
MessageReaction.defaultProps = {
|
||||
shortcode: undefined,
|
||||
};
|
||||
MessageReaction.propTypes = {
|
||||
shortcodeToEmoji: PropTypes.shape({}).isRequired,
|
||||
reaction: PropTypes.node.isRequired,
|
||||
shortcode: PropTypes.string,
|
||||
count: PropTypes.number.isRequired,
|
||||
users: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
|
@ -401,11 +401,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||
const { roomId, room, reactionTimeline } = roomTimeline;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const reactions = {};
|
||||
const shortcodeToEmoji = getShortcodeToCustomEmoji(room);
|
||||
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
||||
|
||||
const eventReactions = reactionTimeline.get(mEvent.getId());
|
||||
const addReaction = (key, count, senderId, isActive) => {
|
||||
const addReaction = (key, shortcode, count, senderId, isActive) => {
|
||||
let reaction = reactions[key];
|
||||
if (reaction === undefined) {
|
||||
reaction = {
|
||||
|
@ -414,6 +413,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||
isActive: false,
|
||||
};
|
||||
}
|
||||
if (shortcode) reaction.shortcode = shortcode;
|
||||
if (count) {
|
||||
reaction.count = count;
|
||||
} else {
|
||||
|
@ -429,9 +429,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||
if (rEvent.getRelation() === null) return;
|
||||
const reaction = rEvent.getRelation();
|
||||
const senderId = rEvent.getSender();
|
||||
const { shortcode } = rEvent.getContent();
|
||||
const isActive = senderId === mx.getUserId();
|
||||
|
||||
addReaction(reaction.key, undefined, senderId, isActive);
|
||||
addReaction(reaction.key, shortcode, undefined, senderId, isActive);
|
||||
});
|
||||
} else {
|
||||
// Use aggregated reactions
|
||||
|
@ -439,7 +440,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||
if (!aggregatedReaction) return null;
|
||||
aggregatedReaction.forEach((reaction) => {
|
||||
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) => (
|
||||
<MessageReaction
|
||||
key={key}
|
||||
shortcodeToEmoji={shortcodeToEmoji}
|
||||
reaction={key}
|
||||
shortcode={reactions[key].shortcode}
|
||||
count={reactions[key].count}
|
||||
users={reactions[key].users}
|
||||
isActive={reactions[key].isActive}
|
||||
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>;
|
||||
|
||||
let msgType = mE.getContent()?.msgtype;
|
||||
if (mE.getType() === 'm.sticker') msgType = 'm.image';
|
||||
if (mE.getType() === 'm.sticker') msgType = 'm.sticker';
|
||||
|
||||
switch (msgType) {
|
||||
case 'm.file':
|
||||
|
@ -630,6 +631,17 @@ function genMediaContent(mE) {
|
|||
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':
|
||||
return (
|
||||
<Media.Audio
|
||||
|
|
|
@ -250,7 +250,6 @@
|
|||
cursor: pointer;
|
||||
|
||||
& .react-emoji {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
|
130
src/app/molecules/room-emojis/RoomEmojis.jsx
Normal file
130
src/app/molecules/room-emojis/RoomEmojis.jsx
Normal 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;
|
29
src/app/molecules/room-emojis/RoomEmojis.scss
Normal file
29
src/app/molecules/room-emojis/RoomEmojis.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -70,7 +70,7 @@ const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
|||
unicode={`:${emoji.shortcode}:`}
|
||||
shortcodes={emoji.shortcode}
|
||||
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) {
|
||||
const unicode = target.getAttribute('unicode');
|
||||
const hexcode = target.getAttribute('hexcode');
|
||||
const mxc = target.getAttribute('data-mx-emoticon');
|
||||
let shortcodes = target.getAttribute('shortcodes');
|
||||
if (typeof shortcodes === 'undefined') shortcodes = undefined;
|
||||
else shortcodes = shortcodes.split(',');
|
||||
return { unicode, hexcode, shortcodes };
|
||||
return {
|
||||
unicode, hexcode, shortcodes, mxc,
|
||||
};
|
||||
}
|
||||
|
||||
function selectEmoji(e) {
|
||||
|
@ -202,21 +205,23 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||
setAvailableEmojis([]);
|
||||
return;
|
||||
}
|
||||
// Retrieve the packs for the new room
|
||||
// Remove packs that aren't marked as emoji packs
|
||||
// Remove packs without emojis
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
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(
|
||||
initMatrix.matrixClient.getRoom(selectedRoomId),
|
||||
)
|
||||
.filter((pack) => pack.usage.indexOf('emoticon') !== -1)
|
||||
.filter((pack) => pack.getEmojis().length !== 0);
|
||||
room.client,
|
||||
[room, ...parentRooms],
|
||||
).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
|
||||
for (let i = 0; i < packs.length; i += 1) {
|
||||
packs[i].packIndex = i;
|
||||
}
|
||||
|
||||
setAvailableEmojis(packs);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
|
@ -260,7 +265,7 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||
{
|
||||
availableEmojis.map((pack) => (
|
||||
<EmojiGroup
|
||||
name={pack.displayName}
|
||||
name={pack.displayName ?? 'Unknown'}
|
||||
key={pack.packIndex}
|
||||
groupEmojis={pack.getEmojis()}
|
||||
className="custom-emoji-group"
|
||||
|
@ -293,13 +298,14 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||
<div className="emoji-board__nav-custom">
|
||||
{
|
||||
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 (
|
||||
<IconButton
|
||||
onClick={() => openGroup(recentOffset + pack.packIndex)}
|
||||
src={src}
|
||||
key={pack.packIndex}
|
||||
tooltip={pack.displayName}
|
||||
tooltip={pack.displayName ?? 'Unknown'}
|
||||
tooltipPlacement="right"
|
||||
isImage
|
||||
/>
|
||||
|
|
|
@ -84,6 +84,7 @@
|
|||
.emoji {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
& > p:last-child {
|
||||
|
@ -123,6 +124,7 @@
|
|||
& .emoji {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
object-fit: contain;
|
||||
padding: var(--emoji-padding);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
|
|
|
@ -1,135 +1,224 @@
|
|||
import { emojis } from './emoji';
|
||||
|
||||
// Custom emoji are stored in one of three places:
|
||||
// - 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
|
||||
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||
|
||||
class ImagePack {
|
||||
// Convert a raw image pack into a more maliable format
|
||||
//
|
||||
// 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') {
|
||||
static parsePack(eventId, packContent) {
|
||||
if (!eventId || typeof packContent?.images !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pack = rawPack.pack ?? {};
|
||||
return new ImagePack(eventId, packContent);
|
||||
}
|
||||
|
||||
const displayName = pack.display_name ?? (room ? room.name : undefined);
|
||||
const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined);
|
||||
const usage = pack.usage ?? ['emoticon', 'sticker'];
|
||||
const { attribution } = pack;
|
||||
const images = Object.entries(rawPack.images).flatMap((e) => {
|
||||
const data = e[1];
|
||||
const shortcode = e[0];
|
||||
constructor(eventId, content) {
|
||||
this.id = eventId;
|
||||
this.content = JSON.parse(JSON.stringify(content));
|
||||
|
||||
this.applyPack(content);
|
||||
this.applyImages(content);
|
||||
}
|
||||
|
||||
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 body = data.body ?? shortcode;
|
||||
const usage = data.usage ?? this.usage;
|
||||
const { info } = data;
|
||||
const usage_ = data.usage ?? usage;
|
||||
|
||||
if (mxc) {
|
||||
return [{
|
||||
shortcode, mxc, body, info, usage: usage_,
|
||||
}];
|
||||
if (!mxc) return;
|
||||
const image = {
|
||||
shortcode, mxc, body, usage, info,
|
||||
};
|
||||
|
||||
this.images.set(shortcode, image);
|
||||
if (usage.includes('emoticon')) {
|
||||
this.emoticons.push(image);
|
||||
}
|
||||
return [];
|
||||
if (usage.includes('sticker')) {
|
||||
this.stickers.push(image);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getImages() {
|
||||
return this.images;
|
||||
}
|
||||
|
||||
getEmojis() {
|
||||
return this.emoticons;
|
||||
}
|
||||
|
||||
getStickers() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalImagePacks(mx) {
|
||||
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
|
||||
if (typeof globalContent !== 'object') return [];
|
||||
|
||||
const { rooms } = globalContent;
|
||||
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 new ImagePack(displayName, avatar, usage, attribution, images);
|
||||
return packs;
|
||||
}
|
||||
|
||||
constructor(displayName, avatar, usage, attribution, images) {
|
||||
this.displayName = displayName;
|
||||
this.avatar = avatar;
|
||||
this.usage = usage;
|
||||
this.attribution = attribution;
|
||||
this.images = images;
|
||||
}
|
||||
|
||||
// Produce a list of emoji in this image pack
|
||||
getEmojis() {
|
||||
return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1);
|
||||
}
|
||||
|
||||
// Produce a list of stickers in this image pack
|
||||
getStickers() {
|
||||
return this.images.filter((i) => i.usage.indexOf('sticker') !== -1);
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve a list of user emojis
|
||||
//
|
||||
// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal
|
||||
// image pack.
|
||||
//
|
||||
// Accepts a reference to a matrix client as the only argument
|
||||
function getUserImagePack(mx) {
|
||||
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
||||
if (!accountDataEmoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content);
|
||||
if (userImagePack) userImagePack.displayName ??= 'Your Emoji';
|
||||
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
|
||||
userImagePack.displayName ??= 'Personal Emoji';
|
||||
return userImagePack;
|
||||
}
|
||||
|
||||
// Produces a list of all of the emoji packs in a room
|
||||
//
|
||||
// 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');
|
||||
function getRoomImagePacks(room) {
|
||||
const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
|
||||
|
||||
return packs
|
||||
.map((p) => ImagePack.parsePack(p.event.content, room))
|
||||
.filter((p) => p !== null);
|
||||
return dataEvents
|
||||
.map((data) => {
|
||||
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
|
||||
//
|
||||
// This includes packs in that room, the user's personal images, and will eventually
|
||||
// include the user's enabled global image packs and space-level packs.
|
||||
//
|
||||
// This differs from getPacksInRoom, as the former only returns packs that are directly in
|
||||
// a room, whereas this function returns all packs which should be shown to the user while
|
||||
// they are in this room.
|
||||
//
|
||||
// Packs will be returned in the order that shortcode conflicts should be resolved, with
|
||||
// higher priority packs coming first.
|
||||
function getRelevantPacks(room) {
|
||||
/**
|
||||
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
|
||||
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
|
||||
* @returns {ImagePack[]} packs
|
||||
*/
|
||||
function getRelevantPacks(mx, rooms) {
|
||||
const userPack = mx ? getUserImagePack(mx) : [];
|
||||
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
|
||||
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
|
||||
|
||||
return [].concat(
|
||||
getUserImagePack(room.client) ?? [],
|
||||
getPacksInRoom(room),
|
||||
userPack ?? [],
|
||||
globalPacks,
|
||||
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
|
||||
);
|
||||
}
|
||||
|
||||
// Returns all user+room emojis and all standard unicode emojis
|
||||
//
|
||||
// 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) {
|
||||
function getShortcodeToEmoji(mx, rooms) {
|
||||
const allEmoji = new Map();
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
if (emoji.shortcodes.constructor.name === 'Array') {
|
||||
if (Array.isArray(emoji.shortcodes)) {
|
||||
emoji.shortcodes.forEach((shortcode) => {
|
||||
allEmoji.set(shortcode, emoji);
|
||||
});
|
||||
|
@ -138,7 +227,7 @@ function getShortcodeToEmoji(room) {
|
|||
}
|
||||
});
|
||||
|
||||
getRelevantPacks(room).reverse()
|
||||
getRelevantPacks(mx, rooms)
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
|
@ -150,7 +239,7 @@ function getShortcodeToEmoji(room) {
|
|||
function getShortcodeToCustomEmoji(room) {
|
||||
const allEmoji = new Map();
|
||||
|
||||
getRelevantPacks(room).reverse()
|
||||
getRelevantPacks(room.client, [room])
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
|
@ -159,27 +248,20 @@ function getShortcodeToCustomEmoji(room) {
|
|||
return allEmoji;
|
||||
}
|
||||
|
||||
// Produces a special list of emoji specifically for auto-completion
|
||||
//
|
||||
// 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) {
|
||||
function getEmojiForCompletion(mx, rooms) {
|
||||
const allEmoji = new Map();
|
||||
getRelevantPacks(room).reverse()
|
||||
getRelevantPacks(mx, rooms)
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
});
|
||||
|
||||
return emojis.filter((e) => !allEmoji.has(e.shortcode))
|
||||
.concat(Array.from(allEmoji.values()));
|
||||
return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
|
||||
}
|
||||
|
||||
export {
|
||||
getUserImagePack,
|
||||
ImagePack,
|
||||
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
|
||||
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
||||
getRelevantPacks, getEmojiForCompletion,
|
||||
};
|
||||
|
|
|
@ -25,9 +25,11 @@ import RoomHistoryVisibility from '../../molecules/room-history-visibility/RoomH
|
|||
import RoomEncryption from '../../molecules/room-encryption/RoomEncryption';
|
||||
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
|
||||
import RoomMembers from '../../molecules/room-members/RoomMembers';
|
||||
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
|
||||
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.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 ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
||||
|
@ -42,6 +44,7 @@ const tabText = {
|
|||
GENERAL: 'General',
|
||||
SEARCH: 'Search',
|
||||
MEMBERS: 'Members',
|
||||
EMOJIS: 'Emojis',
|
||||
PERMISSIONS: 'Permissions',
|
||||
SECURITY: 'Security',
|
||||
};
|
||||
|
@ -58,6 +61,10 @@ const tabItems = [{
|
|||
iconSrc: UserIC,
|
||||
text: tabText.MEMBERS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: EmojiIC,
|
||||
text: tabText.EMOJIS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: ShieldUserIC,
|
||||
text: tabText.PERMISSIONS,
|
||||
|
@ -197,6 +204,7 @@ function RoomSettings({ roomId }) {
|
|||
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
||||
{selectedTab.text === tabText.SEARCH && <RoomSearch 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.SECURITY && <SecuritySettings roomId={roomId} />}
|
||||
</div>
|
||||
|
@ -210,7 +218,5 @@ RoomSettings.propTypes = {
|
|||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export {
|
||||
RoomSettings as default,
|
||||
tabText,
|
||||
};
|
||||
export default RoomSettings;
|
||||
export { tabText };
|
||||
|
|
|
@ -21,7 +21,7 @@ import AsyncSearch from '../../../util/AsyncSearch';
|
|||
import Text from '../../atoms/text/Text';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
||||
import { addRecentEmoji } from '../emoji-board/recent';
|
||||
import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
|
||||
|
||||
const commands = [{
|
||||
name: 'markdown',
|
||||
|
@ -213,9 +213,15 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
|||
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 });
|
||||
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) => ({
|
||||
|
@ -247,7 +253,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
|||
}
|
||||
if (myCmd.prefix === '@') {
|
||||
viewEvent.emit('cmd_fired', {
|
||||
replace: myCmd.result.name,
|
||||
replace: `@${myCmd.result.userId}`,
|
||||
});
|
||||
}
|
||||
deactivateCmd();
|
||||
|
|
|
@ -8,7 +8,7 @@ import TextareaAutosize from 'react-autosize-textarea';
|
|||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
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 { bytesToSize, getEventCords } from '../../../util/common';
|
||||
import { getUsername } from '../../../util/matrixUtil';
|
||||
|
@ -20,9 +20,12 @@ import IconButton from '../../atoms/button/IconButton';
|
|||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import { MessageReply } from '../../molecules/message/Message';
|
||||
|
||||
import StickerBoard from '../sticker-board/StickerBoard';
|
||||
|
||||
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.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 VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
|
||||
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
|
||||
|
@ -128,7 +131,11 @@ function RoomViewInput({
|
|||
}
|
||||
function firedCmd(cmdData) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
@ -199,6 +206,33 @@ function RoomViewInput({
|
|||
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) {
|
||||
const isEmptyMsg = msg === '';
|
||||
|
||||
|
@ -338,6 +372,29 @@ function RoomViewInput({
|
|||
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
|
||||
</div>
|
||||
<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
|
||||
onClick={(e) => {
|
||||
const cords = getEventCords(e);
|
||||
|
|
|
@ -24,6 +24,7 @@ import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
|||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
|
||||
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 CrossSigning from './CrossSigning';
|
||||
|
@ -31,6 +32,7 @@ import KeyBackup from './KeyBackup';
|
|||
import DeviceManage from './DeviceManage';
|
||||
|
||||
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 BellIC from '../../../../public/res/ic/outlined/bell.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() {
|
||||
return (
|
||||
<div className="settings-security">
|
||||
|
@ -250,6 +261,7 @@ function AboutSection() {
|
|||
export const tabText = {
|
||||
APPEARANCE: 'Appearance',
|
||||
NOTIFICATIONS: 'Notifications',
|
||||
EMOJI: 'Emoji',
|
||||
SECURITY: 'Security',
|
||||
ABOUT: 'About',
|
||||
};
|
||||
|
@ -263,6 +275,11 @@ const tabItems = [{
|
|||
iconSrc: BellIC,
|
||||
disabled: false,
|
||||
render: () => <NotificationsSection />,
|
||||
}, {
|
||||
text: tabText.EMOJI,
|
||||
iconSrc: EmojiIC,
|
||||
disabled: false,
|
||||
render: () => <EmojiSection />,
|
||||
}, {
|
||||
text: tabText.SECURITY,
|
||||
iconSrc: LockIC,
|
||||
|
|
|
@ -40,7 +40,8 @@
|
|||
.settings-notifications,
|
||||
.settings-security__card,
|
||||
.settings-security .device-manage,
|
||||
.settings-about__card {
|
||||
.settings-about__card,
|
||||
.settings-emoji__card {
|
||||
@extend .settings-window__card;
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
|
|||
import RoomAliases from '../../molecules/room-aliases/RoomAliases';
|
||||
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
|
||||
import RoomMembers from '../../molecules/room-members/RoomMembers';
|
||||
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
|
||||
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.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 CategoryIC from '../../../../public/res/ic/outlined/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 { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
|
@ -42,6 +44,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate';
|
|||
const tabText = {
|
||||
GENERAL: 'General',
|
||||
MEMBERS: 'Members',
|
||||
EMOJIS: 'Emojis',
|
||||
PERMISSIONS: 'Permissions',
|
||||
};
|
||||
|
||||
|
@ -53,6 +56,10 @@ const tabItems = [{
|
|||
iconSrc: UserIC,
|
||||
text: tabText.MEMBERS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: EmojiIC,
|
||||
text: tabText.EMOJIS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: ShieldUserIC,
|
||||
text: tabText.PERMISSIONS,
|
||||
|
@ -178,6 +185,7 @@ function SpaceSettings() {
|
|||
<div className="space-settings__cards-wrapper">
|
||||
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
||||
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
|
||||
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
|
||||
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
88
src/app/organisms/sticker-board/StickerBoard.jsx
Normal file
88
src/app/organisms/sticker-board/StickerBoard.jsx
Normal 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;
|
60
src/app/organisms/sticker-board/StickerBoard.scss
Normal file
60
src/app/organisms/sticker-board/StickerBoard.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
try {
|
||||
await mx.sendEvent(roomId, 'm.reaction', {
|
||||
const content = {
|
||||
'm.relates_to': {
|
||||
event_id: toEventId,
|
||||
key: reaction,
|
||||
rel_type: 'm.annotation',
|
||||
},
|
||||
});
|
||||
};
|
||||
if (typeof shortcode === 'string') content.shortcode = shortcode;
|
||||
try {
|
||||
await mx.sendEvent(roomId, 'm.reaction', content);
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@ class InitMatrix extends EventEmitter {
|
|||
if (prevState === null) {
|
||||
this.roomList = new RoomList(this.matrixClient);
|
||||
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.emit('init_loading_finished');
|
||||
}
|
||||
|
|
|
@ -5,21 +5,10 @@ import encrypt from 'browser-encrypt-attachment';
|
|||
import { math } from 'micromark-extension-math';
|
||||
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
|
||||
import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
|
||||
import { getImageDimension } from '../../util/common';
|
||||
import cons from './cons';
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
|
@ -120,14 +109,13 @@ function bindReplyToContent(roomId, reply, content) {
|
|||
return newContent;
|
||||
}
|
||||
|
||||
// Apply formatting to a plain text message
|
||||
//
|
||||
// This includes inserting any custom emoji that might be relevant, and (only if the
|
||||
// user has enabled it in their settings) formatting the message using markdown.
|
||||
function formatAndEmojifyText(room, text) {
|
||||
const allEmoji = getShortcodeToEmoji(room);
|
||||
function formatAndEmojifyText(mx, roomList, roomId, text) {
|
||||
const room = mx.getRoom(roomId);
|
||||
const { userIdsToDisplayNames } = room.currentState;
|
||||
const parentIds = roomList.getAllParentSpaces(roomId);
|
||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||
const allEmoji = getShortcodeToEmoji(mx, [room, ...parentRooms]);
|
||||
|
||||
// Start by applying markdown formatting (if relevant)
|
||||
let formattedText;
|
||||
if (settings.isMarkdown) {
|
||||
formattedText = getFormattedBody(text);
|
||||
|
@ -135,17 +123,25 @@ function formatAndEmojifyText(room, text) {
|
|||
formattedText = text;
|
||||
}
|
||||
|
||||
// Check to see if there are any :shortcode-style-tags: in the message
|
||||
Array.from(formattedText.matchAll(/\B:([\w-]+):\B/g))
|
||||
// Then filter to only the ones corresponding to a valid emoji
|
||||
.filter((match) => allEmoji.has(match[1]))
|
||||
// Reversing the array ensures that indices are preserved as we start replacing
|
||||
const MXID_REGEX = /\B@\S+:\S+\.\S+[^.,:;?!\s]/g;
|
||||
Array.from(formattedText.matchAll(MXID_REGEX))
|
||||
.filter((mxidMatch) => userIdsToDisplayNames[mxidMatch[0]])
|
||||
.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) => {
|
||||
const emoji = allEmoji.get(shortcodeMatch[1]);
|
||||
|
||||
// Render the tag that will replace the shortcode
|
||||
let tag;
|
||||
if (emoji.mxc) {
|
||||
tag = `<img data-mx-emoticon="" src="${
|
||||
|
@ -159,7 +155,6 @@ function formatAndEmojifyText(room, text) {
|
|||
tag = emoji.unicode;
|
||||
}
|
||||
|
||||
// Splice the tag into the text
|
||||
formattedText = formattedText.substr(0, shortcodeMatch.index)
|
||||
+ tag
|
||||
+ formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length);
|
||||
|
@ -169,10 +164,11 @@ function formatAndEmojifyText(room, text) {
|
|||
}
|
||||
|
||||
class RoomsInput extends EventEmitter {
|
||||
constructor(mx) {
|
||||
constructor(mx, roomList) {
|
||||
super();
|
||||
|
||||
this.matrixClient = mx;
|
||||
this.roomList = roomList;
|
||||
this.roomIdToInput = new Map();
|
||||
}
|
||||
|
||||
|
@ -273,7 +269,9 @@ class RoomsInput extends EventEmitter {
|
|||
|
||||
// Apply formatting if relevant
|
||||
const formattedBody = formatAndEmojifyText(
|
||||
this.matrixClient.getRoom(roomId),
|
||||
this.matrixClient,
|
||||
this.roomList,
|
||||
roomId,
|
||||
input.message,
|
||||
);
|
||||
if (formattedBody !== input.message) {
|
||||
|
@ -412,7 +410,9 @@ class RoomsInput extends EventEmitter {
|
|||
|
||||
// Apply formatting if relevant
|
||||
const formattedBody = formatAndEmojifyText(
|
||||
this.matrixClient.getRoom(roomId),
|
||||
this.matrixClient,
|
||||
this.roomList,
|
||||
roomId,
|
||||
editedBody,
|
||||
);
|
||||
if (formattedBody !== editedBody) {
|
||||
|
|
|
@ -132,3 +132,62 @@ export function copyToClipboard(text) {
|
|||
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;
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue