mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-15 11:40:29 +03:00
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
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue