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 1782 additions and 204 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 {
|
&--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) {
|
||||||
.text {
|
.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 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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,6 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
& .react-emoji {
|
& .react-emoji {
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin: 2px;
|
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}:`}
|
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
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue