Custom emoji & Sticker support (#686)
* Remove comments * Show custom emoji first in suggestions * Show global image packs in emoji picker * Display emoji and sticker in room settings * Fix some pack not visible in emojiboard * WIP * Add/delete/rename images to exisitng packs * Change pack avatar, name & attribution * Add checkbox to make pack global * Bug fix * Create or delete pack * Add personal emoji in settings * Show global pack selector in settings * Show space emoji in emojiboard * Send custom emoji reaction as mxc * Render stickers as stickers * Fix sticker jump bug * Fix reaction width * Fix stretched custom emoji * Fix sending space emoji in message * Remove unnessesary comments * Send user pills * Fix pill generating regex * Add support for sending stickers
This commit is contained in:
parent
5e527e434a
commit
edace32213
33 changed files with 1781 additions and 203 deletions
4
public/res/ic/outlined/sticker.svg
Normal file
4
public/res/ic/outlined/sticker.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 3L21 8V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H16ZM19 9H17C15.8954 9 15 8.10457 15 7V5H5V19H19V9Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12H9Z" fill="black"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 501 B |
|
@ -26,10 +26,10 @@
|
||||||
&--icon {
|
&--icon {
|
||||||
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
||||||
|
|
||||||
.ic-raw {
|
}
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
.ic-raw {
|
||||||
flex-shrink: 0;
|
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||||
}
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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
|
|
||||||
// Remove packs without emojis
|
|
||||||
const packs = getRelevantPacks(
|
|
||||||
initMatrix.matrixClient.getRoom(selectedRoomId),
|
|
||||||
)
|
|
||||||
.filter((pack) => pack.usage.indexOf('emoticon') !== -1)
|
|
||||||
.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
|
const mx = initMatrix.matrixClient;
|
||||||
for (let i = 0; i < packs.length; i += 1) {
|
const room = mx.getRoom(selectedRoomId);
|
||||||
packs[i].packIndex = i;
|
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
||||||
|
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||||
|
if (room) {
|
||||||
|
const packs = getRelevantPacks(
|
||||||
|
room.client,
|
||||||
|
[room, ...parentRooms],
|
||||||
|
).filter((pack) => pack.getEmojis().length !== 0);
|
||||||
|
|
||||||
|
// Set an index for each pack so that we know where to jump when the user uses the nav
|
||||||
|
for (let i = 0; i < packs.length; i += 1) {
|
||||||
|
packs[i].packIndex = i;
|
||||||
|
}
|
||||||
|
setAvailableEmojis(packs);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|
|
@ -1,135 +1,224 @@
|
||||||
import { emojis } from './emoji';
|
import { emojis } from './emoji';
|
||||||
|
|
||||||
// Custom emoji are stored in one of three places:
|
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||||
// - User emojis, which are stored in account data
|
|
||||||
// - Room emojis, which are stored in state events in a room
|
|
||||||
// - Emoji packs, which are rooms of emojis referenced in the account data or in a room's
|
|
||||||
// cannonical space
|
|
||||||
//
|
|
||||||
// Emojis and packs referenced from within a user's account data should be available
|
|
||||||
// globally, while emojis and packs in rooms and spaces should only be available within
|
|
||||||
// those spaces and rooms
|
|
||||||
|
|
||||||
class ImagePack {
|
class ImagePack {
|
||||||
// Convert a raw image pack into a more maliable format
|
static parsePack(eventId, packContent) {
|
||||||
//
|
if (!eventId || typeof packContent?.images !== 'object') {
|
||||||
// Takes an image pack as per MSC 2545 (e.g. as in the Matrix spec), and converts it to a
|
|
||||||
// format used here, while filling in defaults.
|
|
||||||
//
|
|
||||||
// The room argument is the room the pack exists in, which is used as a fallback for
|
|
||||||
// missing properties
|
|
||||||
//
|
|
||||||
// Returns `null` if the rawPack is not a properly formatted image pack, although there
|
|
||||||
// is still a fair amount of tolerance for malformed packs.
|
|
||||||
static parsePack(rawPack, room) {
|
|
||||||
if (typeof rawPack.images === 'undefined') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pack = rawPack.pack ?? {};
|
return new ImagePack(eventId, packContent);
|
||||||
|
}
|
||||||
|
|
||||||
const displayName = pack.display_name ?? (room ? room.name : undefined);
|
constructor(eventId, content) {
|
||||||
const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined);
|
this.id = eventId;
|
||||||
const usage = pack.usage ?? ['emoticon', 'sticker'];
|
this.content = JSON.parse(JSON.stringify(content));
|
||||||
const { attribution } = pack;
|
|
||||||
const images = Object.entries(rawPack.images).flatMap((e) => {
|
this.applyPack(content);
|
||||||
const data = e[1];
|
this.applyImages(content);
|
||||||
const shortcode = e[0];
|
}
|
||||||
|
|
||||||
|
applyPack(content) {
|
||||||
|
const pack = content.pack ?? {};
|
||||||
|
|
||||||
|
this.displayName = pack.display_name;
|
||||||
|
this.avatarUrl = pack.avatar_url;
|
||||||
|
this.usage = pack.usage ?? ['emoticon', 'sticker'];
|
||||||
|
this.attribution = pack.attribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyImages(content) {
|
||||||
|
this.images = new Map();
|
||||||
|
this.emoticons = [];
|
||||||
|
this.stickers = [];
|
||||||
|
|
||||||
|
Object.entries(content.images).forEach(([shortcode, data]) => {
|
||||||
const mxc = data.url;
|
const mxc = data.url;
|
||||||
const body = data.body ?? shortcode;
|
const body = data.body ?? shortcode;
|
||||||
|
const usage = data.usage ?? this.usage;
|
||||||
const { info } = data;
|
const { info } = data;
|
||||||
const usage_ = data.usage ?? usage;
|
|
||||||
|
|
||||||
if (mxc) {
|
if (!mxc) return;
|
||||||
return [{
|
const image = {
|
||||||
shortcode, mxc, body, info, usage: usage_,
|
shortcode, mxc, body, usage, info,
|
||||||
}];
|
};
|
||||||
|
|
||||||
|
this.images.set(shortcode, image);
|
||||||
|
if (usage.includes('emoticon')) {
|
||||||
|
this.emoticons.push(image);
|
||||||
|
}
|
||||||
|
if (usage.includes('sticker')) {
|
||||||
|
this.stickers.push(image);
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return new ImagePack(displayName, avatar, usage, attribution, images);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(displayName, avatar, usage, attribution, images) {
|
getImages() {
|
||||||
this.displayName = displayName;
|
return this.images;
|
||||||
this.avatar = avatar;
|
|
||||||
this.usage = usage;
|
|
||||||
this.attribution = attribution;
|
|
||||||
this.images = images;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produce a list of emoji in this image pack
|
|
||||||
getEmojis() {
|
getEmojis() {
|
||||||
return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1);
|
return this.emoticons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produce a list of stickers in this image pack
|
|
||||||
getStickers() {
|
getStickers() {
|
||||||
return this.images.filter((i) => i.usage.indexOf('sticker') !== -1);
|
return this.stickers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent() {
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatePackProperty(property, value) {
|
||||||
|
if (this.content.pack === undefined) {
|
||||||
|
this.content.pack = {};
|
||||||
|
}
|
||||||
|
this.content.pack[property] = value;
|
||||||
|
this.applyPack(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarUrl(avatarUrl) {
|
||||||
|
this._updatePackProperty('avatar_url', avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayName(displayName) {
|
||||||
|
this._updatePackProperty('display_name', displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribution(attribution) {
|
||||||
|
this._updatePackProperty('attribution', attribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsage(usage) {
|
||||||
|
this._updatePackProperty('usage', usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
addImage(key, imgContent) {
|
||||||
|
this.content.images = {
|
||||||
|
[key]: imgContent,
|
||||||
|
...this.content.images,
|
||||||
|
};
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeImage(key) {
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
delete this.content.images[key];
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImageKey(key, newKey) {
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
const copyImages = {};
|
||||||
|
Object.keys(this.content.images).forEach((imgKey) => {
|
||||||
|
copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
|
||||||
|
});
|
||||||
|
this.content.images = copyImages;
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateImageProperty(key, property, value) {
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
this.content.images[key][property] = value;
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUrl(key, url) {
|
||||||
|
this._updateImageProperty(key, 'url', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageBody(key, body) {
|
||||||
|
this._updateImageProperty(key, 'body', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageInfo(key, info) {
|
||||||
|
this._updateImageProperty(key, 'info', info);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUsage(key, usage) {
|
||||||
|
this._updateImageProperty(key, 'usage', usage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve a list of user emojis
|
function getGlobalImagePacks(mx) {
|
||||||
//
|
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
|
||||||
// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal
|
if (typeof globalContent !== 'object') return [];
|
||||||
// image pack.
|
|
||||||
//
|
const { rooms } = globalContent;
|
||||||
// Accepts a reference to a matrix client as the only argument
|
if (typeof rooms !== 'object') return [];
|
||||||
|
|
||||||
|
const roomIds = Object.keys(rooms);
|
||||||
|
|
||||||
|
const packs = roomIds.flatMap((roomId) => {
|
||||||
|
if (typeof rooms[roomId] !== 'object') return [];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return [];
|
||||||
|
const stateKeys = Object.keys(rooms[roomId]);
|
||||||
|
|
||||||
|
return stateKeys.map((stateKey) => {
|
||||||
|
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
|
||||||
|
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||||
|
if (pack) {
|
||||||
|
pack.displayName ??= room.name;
|
||||||
|
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||||
|
}
|
||||||
|
return pack;
|
||||||
|
}).filter((pack) => pack !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return packs;
|
||||||
|
}
|
||||||
|
|
||||||
function getUserImagePack(mx) {
|
function getUserImagePack(mx) {
|
||||||
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
||||||
if (!accountDataEmoji) {
|
if (!accountDataEmoji) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content);
|
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
|
||||||
if (userImagePack) userImagePack.displayName ??= 'Your Emoji';
|
userImagePack.displayName ??= 'Personal Emoji';
|
||||||
return userImagePack;
|
return userImagePack;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produces a list of all of the emoji packs in a room
|
function getRoomImagePacks(room) {
|
||||||
//
|
const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
|
||||||
// Returns a list of `ImagePack`s. This does not include packs in spaces that contain
|
|
||||||
// this room.
|
|
||||||
function getPacksInRoom(room) {
|
|
||||||
const packs = room.currentState.getStateEvents('im.ponies.room_emotes');
|
|
||||||
|
|
||||||
return packs
|
return dataEvents
|
||||||
.map((p) => ImagePack.parsePack(p.event.content, room))
|
.map((data) => {
|
||||||
.filter((p) => p !== null);
|
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||||
|
if (pack) {
|
||||||
|
pack.displayName ??= room.name;
|
||||||
|
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||||
|
}
|
||||||
|
return pack;
|
||||||
|
})
|
||||||
|
.filter((pack) => pack !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produce a list of all image packs which should be shown for a given room
|
/**
|
||||||
//
|
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
|
||||||
// This includes packs in that room, the user's personal images, and will eventually
|
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
|
||||||
// include the user's enabled global image packs and space-level packs.
|
* @returns {ImagePack[]} packs
|
||||||
//
|
*/
|
||||||
// This differs from getPacksInRoom, as the former only returns packs that are directly in
|
function getRelevantPacks(mx, rooms) {
|
||||||
// a room, whereas this function returns all packs which should be shown to the user while
|
const userPack = mx ? getUserImagePack(mx) : [];
|
||||||
// they are in this room.
|
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
|
||||||
//
|
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||||
// Packs will be returned in the order that shortcode conflicts should be resolved, with
|
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
|
||||||
// higher priority packs coming first.
|
|
||||||
function getRelevantPacks(room) {
|
|
||||||
return [].concat(
|
return [].concat(
|
||||||
getUserImagePack(room.client) ?? [],
|
userPack ?? [],
|
||||||
getPacksInRoom(room),
|
globalPacks,
|
||||||
|
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns all user+room emojis and all standard unicode emojis
|
function getShortcodeToEmoji(mx, rooms) {
|
||||||
//
|
|
||||||
// Accepts a reference to a matrix client as the only argument
|
|
||||||
//
|
|
||||||
// Result is a map from shortcode to the corresponding emoji. If two emoji share a
|
|
||||||
// shortcode, only one will be presented, with priority given to custom emoji.
|
|
||||||
//
|
|
||||||
// Will eventually be expanded to include all emojis revelant to a room and the user
|
|
||||||
function getShortcodeToEmoji(room) {
|
|
||||||
const allEmoji = new Map();
|
const allEmoji = new Map();
|
||||||
|
|
||||||
emojis.forEach((emoji) => {
|
emojis.forEach((emoji) => {
|
||||||
if (emoji.shortcodes.constructor.name === 'Array') {
|
if (Array.isArray(emoji.shortcodes)) {
|
||||||
emoji.shortcodes.forEach((shortcode) => {
|
emoji.shortcodes.forEach((shortcode) => {
|
||||||
allEmoji.set(shortcode, emoji);
|
allEmoji.set(shortcode, emoji);
|
||||||
});
|
});
|
||||||
|
@ -138,7 +227,7 @@ function getShortcodeToEmoji(room) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
getRelevantPacks(room).reverse()
|
getRelevantPacks(mx, rooms)
|
||||||
.flatMap((pack) => pack.getEmojis())
|
.flatMap((pack) => pack.getEmojis())
|
||||||
.forEach((emoji) => {
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
|
@ -150,7 +239,7 @@ function getShortcodeToEmoji(room) {
|
||||||
function getShortcodeToCustomEmoji(room) {
|
function getShortcodeToCustomEmoji(room) {
|
||||||
const allEmoji = new Map();
|
const allEmoji = new Map();
|
||||||
|
|
||||||
getRelevantPacks(room).reverse()
|
getRelevantPacks(room.client, [room])
|
||||||
.flatMap((pack) => pack.getEmojis())
|
.flatMap((pack) => pack.getEmojis())
|
||||||
.forEach((emoji) => {
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
|
@ -159,27 +248,20 @@ function getShortcodeToCustomEmoji(room) {
|
||||||
return allEmoji;
|
return allEmoji;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produces a special list of emoji specifically for auto-completion
|
function getEmojiForCompletion(mx, rooms) {
|
||||||
//
|
|
||||||
// This list contains each emoji once, with all emoji being deduplicated by shortcode.
|
|
||||||
// However, the order of the standard emoji will have been preserved, and alternate
|
|
||||||
// shortcodes for the standard emoji will not be considered.
|
|
||||||
//
|
|
||||||
// Standard emoji are guaranteed to be earlier in the list than custom emoji
|
|
||||||
function getEmojiForCompletion(room) {
|
|
||||||
const allEmoji = new Map();
|
const allEmoji = new Map();
|
||||||
getRelevantPacks(room).reverse()
|
getRelevantPacks(mx, rooms)
|
||||||
.flatMap((pack) => pack.getEmojis())
|
.flatMap((pack) => pack.getEmojis())
|
||||||
.forEach((emoji) => {
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
});
|
});
|
||||||
|
|
||||||
return emojis.filter((e) => !allEmoji.has(e.shortcode))
|
return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
|
||||||
.concat(Array.from(allEmoji.values()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getUserImagePack,
|
ImagePack,
|
||||||
|
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
|
||||||
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
||||||
getRelevantPacks, getEmojiForCompletion,
|
getRelevantPacks, getEmojiForCompletion,
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,9 +25,11 @@ import RoomHistoryVisibility from '../../molecules/room-history-visibility/RoomH
|
||||||
import RoomEncryption from '../../molecules/room-encryption/RoomEncryption';
|
import RoomEncryption from '../../molecules/room-encryption/RoomEncryption';
|
||||||
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
|
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
|
||||||
import RoomMembers from '../../molecules/room-members/RoomMembers';
|
import RoomMembers from '../../molecules/room-members/RoomMembers';
|
||||||
|
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
|
||||||
|
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
||||||
|
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||||
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
||||||
|
@ -42,6 +44,7 @@ const tabText = {
|
||||||
GENERAL: 'General',
|
GENERAL: 'General',
|
||||||
SEARCH: 'Search',
|
SEARCH: 'Search',
|
||||||
MEMBERS: 'Members',
|
MEMBERS: 'Members',
|
||||||
|
EMOJIS: 'Emojis',
|
||||||
PERMISSIONS: 'Permissions',
|
PERMISSIONS: 'Permissions',
|
||||||
SECURITY: 'Security',
|
SECURITY: 'Security',
|
||||||
};
|
};
|
||||||
|
@ -58,6 +61,10 @@ const tabItems = [{
|
||||||
iconSrc: UserIC,
|
iconSrc: UserIC,
|
||||||
text: tabText.MEMBERS,
|
text: tabText.MEMBERS,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
}, {
|
||||||
|
iconSrc: EmojiIC,
|
||||||
|
text: tabText.EMOJIS,
|
||||||
|
disabled: false,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: ShieldUserIC,
|
iconSrc: ShieldUserIC,
|
||||||
text: tabText.PERMISSIONS,
|
text: tabText.PERMISSIONS,
|
||||||
|
@ -197,6 +204,7 @@ function RoomSettings({ roomId }) {
|
||||||
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
||||||
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
|
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
|
||||||
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
|
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
|
||||||
|
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
|
||||||
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
|
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
|
||||||
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
|
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
|
||||||
</div>
|
</div>
|
||||||
|
@ -210,7 +218,5 @@ RoomSettings.propTypes = {
|
||||||
roomId: PropTypes.string.isRequired,
|
roomId: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export default RoomSettings;
|
||||||
RoomSettings as default,
|
export { tabText };
|
||||||
tabText,
|
|
||||||
};
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ import AsyncSearch from '../../../util/AsyncSearch';
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
||||||
import { addRecentEmoji } from '../emoji-board/recent';
|
import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
|
||||||
|
|
||||||
const commands = [{
|
const commands = [{
|
||||||
name: 'markdown',
|
name: 'markdown',
|
||||||
|
@ -213,9 +213,15 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
||||||
setCmd({ prefix, suggestions: commands });
|
setCmd({ prefix, suggestions: commands });
|
||||||
},
|
},
|
||||||
':': () => {
|
':': () => {
|
||||||
const emojis = getEmojiForCompletion(mx.getRoom(roomId));
|
const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
|
||||||
|
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||||
|
const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
|
||||||
|
const recentEmoji = getRecentEmojis(20);
|
||||||
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
|
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
|
||||||
setCmd({ prefix, suggestions: emojis.slice(26, 46) });
|
setCmd({
|
||||||
|
prefix,
|
||||||
|
suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
'@': () => {
|
'@': () => {
|
||||||
const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({
|
const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({
|
||||||
|
@ -247,7 +253,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
||||||
}
|
}
|
||||||
if (myCmd.prefix === '@') {
|
if (myCmd.prefix === '@') {
|
||||||
viewEvent.emit('cmd_fired', {
|
viewEvent.emit('cmd_fired', {
|
||||||
replace: myCmd.result.name,
|
replace: `@${myCmd.result.userId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
deactivateCmd();
|
deactivateCmd();
|
||||||
|
|
|
@ -8,7 +8,7 @@ import TextareaAutosize from 'react-autosize-textarea';
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import settings from '../../../client/state/settings';
|
import settings from '../../../client/state/settings';
|
||||||
import { openEmojiBoard } from '../../../client/action/navigation';
|
import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { bytesToSize, getEventCords } from '../../../util/common';
|
import { bytesToSize, getEventCords } from '../../../util/common';
|
||||||
import { getUsername } from '../../../util/matrixUtil';
|
import { getUsername } from '../../../util/matrixUtil';
|
||||||
|
@ -20,9 +20,12 @@ import IconButton from '../../atoms/button/IconButton';
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
import { MessageReply } from '../../molecules/message/Message';
|
import { MessageReply } from '../../molecules/message/Message';
|
||||||
|
|
||||||
|
import StickerBoard from '../sticker-board/StickerBoard';
|
||||||
|
|
||||||
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
||||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||||
import SendIC from '../../../../public/res/ic/outlined/send.svg';
|
import SendIC from '../../../../public/res/ic/outlined/send.svg';
|
||||||
|
import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
|
||||||
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
|
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
|
||||||
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
|
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
|
||||||
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
|
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
|
||||||
|
@ -128,7 +131,11 @@ function RoomViewInput({
|
||||||
}
|
}
|
||||||
function firedCmd(cmdData) {
|
function firedCmd(cmdData) {
|
||||||
const msg = textAreaRef.current.value;
|
const msg = textAreaRef.current.value;
|
||||||
textAreaRef.current.value = replaceCmdWith(msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '');
|
textAreaRef.current.value = replaceCmdWith(
|
||||||
|
msg,
|
||||||
|
cmdCursorPos,
|
||||||
|
typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
|
||||||
|
);
|
||||||
deactivateCmd();
|
deactivateCmd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,6 +206,33 @@ function RoomViewInput({
|
||||||
if (replyTo !== null) setReplyTo(null);
|
if (replyTo !== null) setReplyTo(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendSticker = async (data) => {
|
||||||
|
const { mxc: url, body, httpUrl } = data;
|
||||||
|
const info = {};
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.src = httpUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(httpUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
info.w = img.width;
|
||||||
|
info.h = img.height;
|
||||||
|
info.mimetype = blob.type;
|
||||||
|
info.size = blob.size;
|
||||||
|
info.thumbnail_info = { ...info };
|
||||||
|
info.thumbnail_url = url;
|
||||||
|
} catch {
|
||||||
|
// send sticker without info
|
||||||
|
}
|
||||||
|
|
||||||
|
mx.sendEvent(roomId, 'm.sticker', {
|
||||||
|
body,
|
||||||
|
url,
|
||||||
|
info,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function processTyping(msg) {
|
function processTyping(msg) {
|
||||||
const isEmptyMsg = msg === '';
|
const isEmptyMsg = msg === '';
|
||||||
|
|
||||||
|
@ -338,6 +372,29 @@ function RoomViewInput({
|
||||||
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
|
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
|
||||||
</div>
|
</div>
|
||||||
<div ref={rightOptionsRef} className="room-input__option-container">
|
<div ref={rightOptionsRef} className="room-input__option-container">
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
openReusableContextMenu(
|
||||||
|
'top',
|
||||||
|
(() => {
|
||||||
|
const cords = getEventCords(e);
|
||||||
|
cords.y -= 20;
|
||||||
|
return cords;
|
||||||
|
})(),
|
||||||
|
(closeMenu) => (
|
||||||
|
<StickerBoard
|
||||||
|
roomId={roomId}
|
||||||
|
onSelect={(data) => {
|
||||||
|
handleSendSticker(data);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
tooltip="Sticker"
|
||||||
|
src={StickerIC}
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const cords = getEventCords(e);
|
const cords = getEventCords(e);
|
||||||
|
|
|
@ -24,6 +24,7 @@ import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
||||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||||
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
|
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
|
||||||
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
|
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
|
||||||
|
import { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack';
|
||||||
|
|
||||||
import ProfileEditor from '../profile-editor/ProfileEditor';
|
import ProfileEditor from '../profile-editor/ProfileEditor';
|
||||||
import CrossSigning from './CrossSigning';
|
import CrossSigning from './CrossSigning';
|
||||||
|
@ -31,6 +32,7 @@ import KeyBackup from './KeyBackup';
|
||||||
import DeviceManage from './DeviceManage';
|
import DeviceManage from './DeviceManage';
|
||||||
|
|
||||||
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
||||||
|
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||||
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
||||||
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
|
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
|
||||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||||
|
@ -169,6 +171,15 @@ function NotificationsSection() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmojiSection() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="settings-emoji__card"><ImagePackUser /></div>
|
||||||
|
<div className="settings-emoji__card"><ImagePackGlobal /></div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SecuritySection() {
|
function SecuritySection() {
|
||||||
return (
|
return (
|
||||||
<div className="settings-security">
|
<div className="settings-security">
|
||||||
|
@ -250,6 +261,7 @@ function AboutSection() {
|
||||||
export const tabText = {
|
export const tabText = {
|
||||||
APPEARANCE: 'Appearance',
|
APPEARANCE: 'Appearance',
|
||||||
NOTIFICATIONS: 'Notifications',
|
NOTIFICATIONS: 'Notifications',
|
||||||
|
EMOJI: 'Emoji',
|
||||||
SECURITY: 'Security',
|
SECURITY: 'Security',
|
||||||
ABOUT: 'About',
|
ABOUT: 'About',
|
||||||
};
|
};
|
||||||
|
@ -263,6 +275,11 @@ const tabItems = [{
|
||||||
iconSrc: BellIC,
|
iconSrc: BellIC,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
render: () => <NotificationsSection />,
|
render: () => <NotificationsSection />,
|
||||||
|
}, {
|
||||||
|
text: tabText.EMOJI,
|
||||||
|
iconSrc: EmojiIC,
|
||||||
|
disabled: false,
|
||||||
|
render: () => <EmojiSection />,
|
||||||
}, {
|
}, {
|
||||||
text: tabText.SECURITY,
|
text: tabText.SECURITY,
|
||||||
iconSrc: LockIC,
|
iconSrc: LockIC,
|
||||||
|
|
|
@ -40,7 +40,8 @@
|
||||||
.settings-notifications,
|
.settings-notifications,
|
||||||
.settings-security__card,
|
.settings-security__card,
|
||||||
.settings-security .device-manage,
|
.settings-security .device-manage,
|
||||||
.settings-about__card {
|
.settings-about__card,
|
||||||
|
.settings-emoji__card {
|
||||||
@extend .settings-window__card;
|
@extend .settings-window__card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
|
||||||
import RoomAliases from '../../molecules/room-aliases/RoomAliases';
|
import RoomAliases from '../../molecules/room-aliases/RoomAliases';
|
||||||
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
|
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
|
||||||
import RoomMembers from '../../molecules/room-members/RoomMembers';
|
import RoomMembers from '../../molecules/room-members/RoomMembers';
|
||||||
|
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
|
||||||
|
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
@ -35,6 +36,7 @@ import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||||
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
||||||
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
||||||
|
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||||
|
|
||||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
@ -42,6 +44,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
const tabText = {
|
const tabText = {
|
||||||
GENERAL: 'General',
|
GENERAL: 'General',
|
||||||
MEMBERS: 'Members',
|
MEMBERS: 'Members',
|
||||||
|
EMOJIS: 'Emojis',
|
||||||
PERMISSIONS: 'Permissions',
|
PERMISSIONS: 'Permissions',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -53,6 +56,10 @@ const tabItems = [{
|
||||||
iconSrc: UserIC,
|
iconSrc: UserIC,
|
||||||
text: tabText.MEMBERS,
|
text: tabText.MEMBERS,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
}, {
|
||||||
|
iconSrc: EmojiIC,
|
||||||
|
text: tabText.EMOJIS,
|
||||||
|
disabled: false,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: ShieldUserIC,
|
iconSrc: ShieldUserIC,
|
||||||
text: tabText.PERMISSIONS,
|
text: tabText.PERMISSIONS,
|
||||||
|
@ -178,6 +185,7 @@ function SpaceSettings() {
|
||||||
<div className="space-settings__cards-wrapper">
|
<div className="space-settings__cards-wrapper">
|
||||||
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
||||||
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
|
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
|
||||||
|
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
|
||||||
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
|
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
88
src/app/organisms/sticker-board/StickerBoard.jsx
Normal file
88
src/app/organisms/sticker-board/StickerBoard.jsx
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './StickerBoard.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { getRelevantPacks } from '../emoji-board/custom-emoji';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
|
|
||||||
|
function StickerBoard({ roomId, onSelect }) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
|
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
||||||
|
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||||
|
|
||||||
|
const packs = getRelevantPacks(
|
||||||
|
mx,
|
||||||
|
[room, ...parentRooms],
|
||||||
|
).filter((pack) => pack.getStickers().length !== 0);
|
||||||
|
|
||||||
|
function isTargetNotSticker(target) {
|
||||||
|
return target.classList.contains('sticker-board__sticker') === false;
|
||||||
|
}
|
||||||
|
function getStickerData(target) {
|
||||||
|
const mxc = target.getAttribute('data-mx-sticker');
|
||||||
|
const body = target.getAttribute('title');
|
||||||
|
const httpUrl = target.getAttribute('src');
|
||||||
|
return { mxc, body, httpUrl };
|
||||||
|
}
|
||||||
|
const handleOnSelect = (e) => {
|
||||||
|
if (isTargetNotSticker(e.target)) return;
|
||||||
|
|
||||||
|
const stickerData = getStickerData(e.target);
|
||||||
|
onSelect(stickerData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPack = (pack) => (
|
||||||
|
<div className="sticker-board__pack" key={pack.id}>
|
||||||
|
<Text className="sticker-board__pack-header" variant="b2" weight="bold">{pack.displayName ?? 'Unknown'}</Text>
|
||||||
|
<div className="sticker-board__pack-items">
|
||||||
|
{pack.getStickers().map((sticker) => (
|
||||||
|
<img
|
||||||
|
key={sticker.shortcode}
|
||||||
|
className="sticker-board__sticker"
|
||||||
|
src={mx.mxcUrlToHttp(sticker.mxc)}
|
||||||
|
alt={sticker.shortcode}
|
||||||
|
title={sticker.body ?? sticker.shortcode}
|
||||||
|
data-mx-sticker={sticker.mxc}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticker-board">
|
||||||
|
<div className="sticker-board__container">
|
||||||
|
<ScrollView autoHide>
|
||||||
|
<div
|
||||||
|
onClick={handleOnSelect}
|
||||||
|
className="sticker-board__content"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
packs.length > 0
|
||||||
|
? packs.map(renderPack)
|
||||||
|
: (
|
||||||
|
<div className="sticker-board__empty">
|
||||||
|
<Text>There is no sticker pack.</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ScrollView>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
StickerBoard.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
onSelect: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StickerBoard;
|
60
src/app/organisms/sticker-board/StickerBoard.scss
Normal file
60
src/app/organisms/sticker-board/StickerBoard.scss
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
@use '../../partials/dir';
|
||||||
|
|
||||||
|
.sticker-board {
|
||||||
|
--sticker-board-height: 390px;
|
||||||
|
--sticker-board-width: 286px;
|
||||||
|
display: flex;
|
||||||
|
height: var(--sticker-board-height);
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: var(--sticker-board-width);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pack {
|
||||||
|
margin-bottom: var(--sp-normal);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 99;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
|
||||||
|
@include dir.side(margin, var(--sp-extra-tight), 0);
|
||||||
|
padding: var(--sp-extra-tight) var(--sp-ultra-tight);
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: 0 -4px 0 0 var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--bg-surface-border);
|
||||||
|
}
|
||||||
|
&-items {
|
||||||
|
margin: var(--sp-tight);
|
||||||
|
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-normal) var(--sp-tight);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--sticker-board-height);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,17 +11,18 @@ async function redactEvent(roomId, eventId, reason) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReaction(roomId, toEventId, reaction) {
|
async function sendReaction(roomId, toEventId, reaction, shortcode) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
|
const content = {
|
||||||
|
'm.relates_to': {
|
||||||
|
event_id: toEventId,
|
||||||
|
key: reaction,
|
||||||
|
rel_type: 'm.annotation',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (typeof shortcode === 'string') content.shortcode = shortcode;
|
||||||
try {
|
try {
|
||||||
await mx.sendEvent(roomId, 'm.reaction', {
|
await mx.sendEvent(roomId, 'm.reaction', content);
|
||||||
'm.relates_to': {
|
|
||||||
event_id: toEventId,
|
|
||||||
key: reaction,
|
|
||||||
rel_type: 'm.annotation',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,7 +67,7 @@ class InitMatrix extends EventEmitter {
|
||||||
if (prevState === null) {
|
if (prevState === null) {
|
||||||
this.roomList = new RoomList(this.matrixClient);
|
this.roomList = new RoomList(this.matrixClient);
|
||||||
this.accountData = new AccountData(this.roomList);
|
this.accountData = new AccountData(this.roomList);
|
||||||
this.roomsInput = new RoomsInput(this.matrixClient);
|
this.roomsInput = new RoomsInput(this.matrixClient, this.roomList);
|
||||||
this.notifications = new Notifications(this.roomList);
|
this.notifications = new Notifications(this.roomList);
|
||||||
this.emit('init_loading_finished');
|
this.emit('init_loading_finished');
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,21 +5,10 @@ import encrypt from 'browser-encrypt-attachment';
|
||||||
import { math } from 'micromark-extension-math';
|
import { math } from 'micromark-extension-math';
|
||||||
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
|
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
|
||||||
import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
|
import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
|
||||||
|
import { getImageDimension } from '../../util/common';
|
||||||
import cons from './cons';
|
import cons from './cons';
|
||||||
import settings from './settings';
|
import settings from './settings';
|
||||||
|
|
||||||
function getImageDimension(file) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = async () => {
|
|
||||||
resolve({
|
|
||||||
w: img.width,
|
|
||||||
h: img.height,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function loadVideo(videoFile) {
|
function loadVideo(videoFile) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
|
@ -120,14 +109,13 @@ function bindReplyToContent(roomId, reply, content) {
|
||||||
return newContent;
|
return newContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply formatting to a plain text message
|
function formatAndEmojifyText(mx, roomList, roomId, text) {
|
||||||
//
|
const room = mx.getRoom(roomId);
|
||||||
// This includes inserting any custom emoji that might be relevant, and (only if the
|
const { userIdsToDisplayNames } = room.currentState;
|
||||||
// user has enabled it in their settings) formatting the message using markdown.
|
const parentIds = roomList.getAllParentSpaces(roomId);
|
||||||
function formatAndEmojifyText(room, text) {
|
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||||
const allEmoji = getShortcodeToEmoji(room);
|
const allEmoji = getShortcodeToEmoji(mx, [room, ...parentRooms]);
|
||||||
|
|
||||||
// Start by applying markdown formatting (if relevant)
|
|
||||||
let formattedText;
|
let formattedText;
|
||||||
if (settings.isMarkdown) {
|
if (settings.isMarkdown) {
|
||||||
formattedText = getFormattedBody(text);
|
formattedText = getFormattedBody(text);
|
||||||
|
@ -135,17 +123,25 @@ function formatAndEmojifyText(room, text) {
|
||||||
formattedText = text;
|
formattedText = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check to see if there are any :shortcode-style-tags: in the message
|
const MXID_REGEX = /\B@\S+:\S+\.\S+[^.,:;?!\s]/g;
|
||||||
Array.from(formattedText.matchAll(/\B:([\w-]+):\B/g))
|
Array.from(formattedText.matchAll(MXID_REGEX))
|
||||||
// Then filter to only the ones corresponding to a valid emoji
|
.filter((mxidMatch) => userIdsToDisplayNames[mxidMatch[0]])
|
||||||
.filter((match) => allEmoji.has(match[1]))
|
|
||||||
// Reversing the array ensures that indices are preserved as we start replacing
|
|
||||||
.reverse()
|
.reverse()
|
||||||
// Replace each :shortcode: with an <img/> tag
|
.forEach((mxidMatch) => {
|
||||||
|
const tag = `<a href="https://matrix.to/#/${mxidMatch[0]}">${userIdsToDisplayNames[mxidMatch[0]]}</a>`;
|
||||||
|
|
||||||
|
formattedText = formattedText.substr(0, mxidMatch.index)
|
||||||
|
+ tag
|
||||||
|
+ formattedText.substr(mxidMatch.index + mxidMatch[0].length);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SHORTCODE_REGEX = /\B:([\w-]+):\B/g;
|
||||||
|
Array.from(formattedText.matchAll(SHORTCODE_REGEX))
|
||||||
|
.filter((shortcodeMatch) => allEmoji.has(shortcodeMatch[1]))
|
||||||
|
.reverse() /* Reversing the array ensures that indices are preserved as we start replacing */
|
||||||
.forEach((shortcodeMatch) => {
|
.forEach((shortcodeMatch) => {
|
||||||
const emoji = allEmoji.get(shortcodeMatch[1]);
|
const emoji = allEmoji.get(shortcodeMatch[1]);
|
||||||
|
|
||||||
// Render the tag that will replace the shortcode
|
|
||||||
let tag;
|
let tag;
|
||||||
if (emoji.mxc) {
|
if (emoji.mxc) {
|
||||||
tag = `<img data-mx-emoticon="" src="${
|
tag = `<img data-mx-emoticon="" src="${
|
||||||
|
@ -159,7 +155,6 @@ function formatAndEmojifyText(room, text) {
|
||||||
tag = emoji.unicode;
|
tag = emoji.unicode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Splice the tag into the text
|
|
||||||
formattedText = formattedText.substr(0, shortcodeMatch.index)
|
formattedText = formattedText.substr(0, shortcodeMatch.index)
|
||||||
+ tag
|
+ tag
|
||||||
+ formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length);
|
+ formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length);
|
||||||
|
@ -169,10 +164,11 @@ function formatAndEmojifyText(room, text) {
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoomsInput extends EventEmitter {
|
class RoomsInput extends EventEmitter {
|
||||||
constructor(mx) {
|
constructor(mx, roomList) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.matrixClient = mx;
|
this.matrixClient = mx;
|
||||||
|
this.roomList = roomList;
|
||||||
this.roomIdToInput = new Map();
|
this.roomIdToInput = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -273,7 +269,9 @@ class RoomsInput extends EventEmitter {
|
||||||
|
|
||||||
// Apply formatting if relevant
|
// Apply formatting if relevant
|
||||||
const formattedBody = formatAndEmojifyText(
|
const formattedBody = formatAndEmojifyText(
|
||||||
this.matrixClient.getRoom(roomId),
|
this.matrixClient,
|
||||||
|
this.roomList,
|
||||||
|
roomId,
|
||||||
input.message,
|
input.message,
|
||||||
);
|
);
|
||||||
if (formattedBody !== input.message) {
|
if (formattedBody !== input.message) {
|
||||||
|
@ -412,7 +410,9 @@ class RoomsInput extends EventEmitter {
|
||||||
|
|
||||||
// Apply formatting if relevant
|
// Apply formatting if relevant
|
||||||
const formattedBody = formatAndEmojifyText(
|
const formattedBody = formatAndEmojifyText(
|
||||||
this.matrixClient.getRoom(roomId),
|
this.matrixClient,
|
||||||
|
this.roomList,
|
||||||
|
roomId,
|
||||||
editedBody,
|
editedBody,
|
||||||
);
|
);
|
||||||
if (formattedBody !== editedBody) {
|
if (formattedBody !== editedBody) {
|
||||||
|
|
|
@ -132,3 +132,62 @@ export function copyToClipboard(text) {
|
||||||
copyInput.remove();
|
copyInput.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function suffixRename(name, validator) {
|
||||||
|
let suffix = 2;
|
||||||
|
let newName = name;
|
||||||
|
do {
|
||||||
|
newName = name + suffix;
|
||||||
|
suffix += 1;
|
||||||
|
} while (validator(newName));
|
||||||
|
|
||||||
|
return newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageDimension(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
resolve({
|
||||||
|
w: img.width,
|
||||||
|
h: img.height,
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleDownImage(imageFile, width, height) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const imgURL = URL.createObjectURL(imageFile);
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
let newWidth = img.width;
|
||||||
|
let newHeight = img.height;
|
||||||
|
|
||||||
|
if (newHeight > height) {
|
||||||
|
newWidth = Math.floor(newWidth * (height / newHeight));
|
||||||
|
newHeight = height;
|
||||||
|
}
|
||||||
|
if (newWidth > width) {
|
||||||
|
newHeight = Math.floor(newHeight * (width / newWidth));
|
||||||
|
newWidth = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = newWidth;
|
||||||
|
canvas.height = newHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||||
|
|
||||||
|
canvas.toBlob((thumbnail) => {
|
||||||
|
URL.revokeObjectURL(imgURL);
|
||||||
|
resolve(thumbnail);
|
||||||
|
}, imageFile.type);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = imgURL;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue