--- /dev/null
+<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>
&--icon {
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
- .ic-raw {
- @include dir.side(margin, 0, var(--sp-extra-tight));
- flex-shrink: 0;
- }
+ }
+ .ic-raw {
+ @include dir.side(margin, 0, var(--sp-extra-tight));
+ flex-shrink: 0;
}
}
--- /dev/null
+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 };
--- /dev/null
+@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);
+ }
+ }
+}
--- /dev/null
+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;
--- /dev/null
+@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);
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+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;
--- /dev/null
+@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);
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+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;
--- /dev/null
+@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;
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+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;
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
import Spinner from '../../atoms/spinner/Spinner';
+import RawIcon from '../../atoms/system-icons/RawIcon';
+
+import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
function ImageUpload({
text, bgColor, imageSrc, onUpload, onRequestRemove,
+ size,
}) {
const [uploadPromise, setUploadPromise] = useState(null);
const uploadImageRef = useRef(null);
imageSrc={imageSrc}
text={text}
bgColor={bgColor}
- size="large"
+ size={size}
/>
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
- {uploadPromise === null && <Text variant="b3" weight="bold">Upload</Text>}
+ {uploadPromise === null && (
+ size === 'large'
+ ? <Text variant="b3" weight="bold">Upload</Text>
+ : <RawIcon src={PlusIC} color="white" />
+ )}
{uploadPromise !== null && <Spinner size="small" />}
</div>
</button>
text: null,
bgColor: 'transparent',
imageSrc: null,
+ size: 'large',
};
ImageUpload.propTypes = {
imageSrc: PropTypes.string,
onUpload: PropTypes.func.isRequired,
onRequestRemove: PropTypes.func.isRequired,
+ size: PropTypes.oneOf(['large', 'normal']),
};
export default ImageUpload;
}
}
-function getNativeHeight(width, height) {
- const MEDIA_MAX_WIDTH = 296;
- const scale = MEDIA_MAX_WIDTH / width;
+function getNativeHeight(width, height, maxWidth = 296) {
+ const scale = maxWidth / width;
return scale * height;
}
type: PropTypes.string,
};
+function Sticker({
+ name, height, width, link, file, type,
+}) {
+ const [url, setUrl] = useState(null);
+
+ useEffect(() => {
+ let unmounted = false;
+ async function fetchUrl() {
+ const myUrl = await getUrl(link, type, file);
+ if (unmounted) return;
+ setUrl(myUrl);
+ }
+ fetchUrl();
+ return () => {
+ unmounted = true;
+ };
+ }, []);
+
+ return (
+ <div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
+ { url !== null && <img src={url || link} title={name} alt={name} />}
+ </div>
+ );
+}
+Sticker.defaultProps = {
+ file: null,
+ type: '',
+};
+Sticker.propTypes = {
+ name: PropTypes.string.isRequired,
+ width: null,
+ height: null,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ link: PropTypes.string.isRequired,
+ file: PropTypes.shape({}),
+ type: PropTypes.string,
+};
+
function Audio({
name, link, type, file,
}) {
};
export {
- File, Image, Audio, Video,
+ File, Image, Sticker, Audio, Video,
};
background-size: cover;
}
+.sticker-container {
+ display: inline-flex;
+ max-width: 128px;
+ width: 100%;
+ & img {
+ width: 100% !important;
+ }
+}
+
.image-container {
& img {
max-width: unset !important;
import PropTypes from 'prop-types';
import './Message.scss';
-import { getShortcodeToCustomEmoji } from '../../organisms/emoji-board/custom-emoji';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
return rEvent;
}
-function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
+function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) {
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
if (myAlreadyReactEvent) {
const rId = myAlreadyReactEvent.getId();
redactEvent(roomId, rId);
return;
}
- sendReaction(roomId, eventId, emojiKey);
+ sendReaction(roomId, eventId, emojiKey, shortcode);
}
function pickEmoji(e, roomId, eventId, roomTimeline) {
openEmojiBoard(getEventCords(e), (emoji) => {
- toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
+ toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline);
e.target.click();
});
}
-function genReactionMsg(userIds, reaction) {
+function genReactionMsg(userIds, reaction, shortcode) {
return (
<>
{userIds.map((userId, index) => (
</React.Fragment>
))}
<span style={{ opacity: '.6' }}>{' reacted with '}</span>
- {twemojify(reaction, { className: 'react-emoji' })}
+ {twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
</>
);
}
function MessageReaction({
- shortcodeToEmoji, reaction, count, users, isActive, onClick,
+ reaction, shortcode, count, users, isActive, onClick,
}) {
- const customEmojiMatch = reaction.match(/^:(\S+):$/);
let customEmojiUrl = null;
- if (customEmojiMatch) {
- const customEmoji = shortcodeToEmoji.get(customEmojiMatch[1]);
- customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(customEmoji?.mxc);
+ if (reaction.match(/^mxc:\/\/\S+$/)) {
+ customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
}
return (
<Tooltip
className="msg__reaction-tooltip"
- content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}</Text>}
+ content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}</Text>}
>
<button
onClick={onClick}
>
{
customEmojiUrl
- ? <img className="react-emoji" draggable="false" alt={reaction} src={customEmojiUrl} />
+ ? <img className="react-emoji" draggable="false" alt={shortcode ?? reaction} src={customEmojiUrl} />
: twemojify(reaction, { className: 'react-emoji' })
}
<Text variant="b3" className="msg__reaction-count">{count}</Text>
</Tooltip>
);
}
+MessageReaction.defaultProps = {
+ shortcode: undefined,
+};
MessageReaction.propTypes = {
- shortcodeToEmoji: PropTypes.shape({}).isRequired,
reaction: PropTypes.node.isRequired,
+ shortcode: PropTypes.string,
count: PropTypes.number.isRequired,
users: PropTypes.arrayOf(PropTypes.string).isRequired,
isActive: PropTypes.bool.isRequired,
const { roomId, room, reactionTimeline } = roomTimeline;
const mx = initMatrix.matrixClient;
const reactions = {};
- const shortcodeToEmoji = getShortcodeToCustomEmoji(room);
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
const eventReactions = reactionTimeline.get(mEvent.getId());
- const addReaction = (key, count, senderId, isActive) => {
+ const addReaction = (key, shortcode, count, senderId, isActive) => {
let reaction = reactions[key];
if (reaction === undefined) {
reaction = {
isActive: false,
};
}
+ if (shortcode) reaction.shortcode = shortcode;
if (count) {
reaction.count = count;
} else {
if (rEvent.getRelation() === null) return;
const reaction = rEvent.getRelation();
const senderId = rEvent.getSender();
+ const { shortcode } = rEvent.getContent();
const isActive = senderId === mx.getUserId();
- addReaction(reaction.key, undefined, senderId, isActive);
+ addReaction(reaction.key, shortcode, undefined, senderId, isActive);
});
} else {
// Use aggregated reactions
if (!aggregatedReaction) return null;
aggregatedReaction.forEach((reaction) => {
if (reaction.type !== 'm.reaction') return;
- addReaction(reaction.key, reaction.count, undefined, false);
+ addReaction(reaction.key, undefined, reaction.count, undefined, false);
});
}
Object.keys(reactions).map((key) => (
<MessageReaction
key={key}
- shortcodeToEmoji={shortcodeToEmoji}
reaction={key}
+ shortcode={reactions[key].shortcode}
count={reactions[key].count}
users={reactions[key].users}
isActive={reactions[key].isActive}
onClick={() => {
- toggleEmoji(roomId, mEvent.getId(), key, roomTimeline);
+ toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline);
}}
/>
))
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
let msgType = mE.getContent()?.msgtype;
- if (mE.getType() === 'm.sticker') msgType = 'm.image';
+ if (mE.getType() === 'm.sticker') msgType = 'm.sticker';
switch (msgType) {
case 'm.file':
type={mContent.info?.mimetype}
/>
);
+ case 'm.sticker':
+ return (
+ <Media.Sticker
+ name={mContent.body}
+ width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
+ height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ file={isEncryptedFile ? mContent.file : null}
+ type={mContent.info?.mimetype}
+ />
+ );
case 'm.audio':
return (
<Media.Audio
cursor: pointer;
& .react-emoji {
- width: 16px;
height: 16px;
margin: 2px;
}
--- /dev/null
+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;
--- /dev/null
+.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;
+ }
+}
\ No newline at end of file
unicode={`:${emoji.shortcode}:`}
shortcodes={emoji.shortcode}
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
- data-mx-emoticon
+ data-mx-emoticon={emoji.mxc}
/>
)
}
function getEmojiDataFromTarget(target) {
const unicode = target.getAttribute('unicode');
const hexcode = target.getAttribute('hexcode');
+ const mxc = target.getAttribute('data-mx-emoticon');
let shortcodes = target.getAttribute('shortcodes');
if (typeof shortcodes === 'undefined') shortcodes = undefined;
else shortcodes = shortcodes.split(',');
- return { unicode, hexcode, shortcodes };
+ return {
+ unicode, hexcode, shortcodes, mxc,
+ };
}
function selectEmoji(e) {
setAvailableEmojis([]);
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
- for (let i = 0; i < packs.length; i += 1) {
- packs[i].packIndex = i;
- }
- setAvailableEmojis(packs);
+ const mx = initMatrix.matrixClient;
+ const room = mx.getRoom(selectedRoomId);
+ const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
+ const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
+ if (room) {
+ const packs = getRelevantPacks(
+ room.client,
+ [room, ...parentRooms],
+ ).filter((pack) => pack.getEmojis().length !== 0);
+
+ // Set an index for each pack so that we know where to jump when the user uses the nav
+ for (let i = 0; i < packs.length; i += 1) {
+ packs[i].packIndex = i;
+ }
+ setAvailableEmojis(packs);
+ }
};
const onOpen = () => {
{
availableEmojis.map((pack) => (
<EmojiGroup
- name={pack.displayName}
+ name={pack.displayName ?? 'Unknown'}
key={pack.packIndex}
groupEmojis={pack.getEmojis()}
className="custom-emoji-group"
<div className="emoji-board__nav-custom">
{
availableEmojis.map((pack) => {
- const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc);
+ const src = initMatrix.matrixClient
+ .mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc);
return (
<IconButton
onClick={() => openGroup(recentOffset + pack.packIndex)}
src={src}
key={pack.packIndex}
- tooltip={pack.displayName}
+ tooltip={pack.displayName ?? 'Unknown'}
tooltipPlacement="right"
isImage
/>
.emoji {
width: 32px;
height: 32px;
+ object-fit: contain;
}
}
& > p:last-child {
& .emoji {
width: 38px;
height: 38px;
+ object-fit: contain;
padding: var(--emoji-padding);
cursor: pointer;
&:hover {
import { emojis } from './emoji';
-// Custom emoji are stored in one of three places:
-// - User emojis, which are stored in account data
-// - Room emojis, which are stored in state events in a room
-// - Emoji packs, which are rooms of emojis referenced in the account data or in a room's
-// cannonical space
-//
-// Emojis and packs referenced from within a user's account data should be available
-// globally, while emojis and packs in rooms and spaces should only be available within
-// those spaces and rooms
+// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
class ImagePack {
- // Convert a raw image pack into a more maliable format
- //
- // Takes an image pack as per MSC 2545 (e.g. as in the Matrix spec), and converts it to a
- // format used here, while filling in defaults.
- //
- // The room argument is the room the pack exists in, which is used as a fallback for
- // missing properties
- //
- // Returns `null` if the rawPack is not a properly formatted image pack, although there
- // is still a fair amount of tolerance for malformed packs.
- static parsePack(rawPack, room) {
- if (typeof rawPack.images === 'undefined') {
+ static parsePack(eventId, packContent) {
+ if (!eventId || typeof packContent?.images !== 'object') {
return null;
}
- const pack = rawPack.pack ?? {};
+ return new ImagePack(eventId, packContent);
+ }
+
+ constructor(eventId, content) {
+ this.id = eventId;
+ this.content = JSON.parse(JSON.stringify(content));
+
+ this.applyPack(content);
+ this.applyImages(content);
+ }
+
+ applyPack(content) {
+ const pack = content.pack ?? {};
+
+ this.displayName = pack.display_name;
+ this.avatarUrl = pack.avatar_url;
+ this.usage = pack.usage ?? ['emoticon', 'sticker'];
+ this.attribution = pack.attribution;
+ }
- const displayName = pack.display_name ?? (room ? room.name : undefined);
- const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined);
- const usage = pack.usage ?? ['emoticon', 'sticker'];
- const { attribution } = pack;
- const images = Object.entries(rawPack.images).flatMap((e) => {
- const data = e[1];
- const shortcode = e[0];
+ applyImages(content) {
+ this.images = new Map();
+ this.emoticons = [];
+ this.stickers = [];
+
+ Object.entries(content.images).forEach(([shortcode, data]) => {
const mxc = data.url;
const body = data.body ?? shortcode;
+ const usage = data.usage ?? this.usage;
const { info } = data;
- const usage_ = data.usage ?? usage;
- if (mxc) {
- return [{
- shortcode, mxc, body, info, usage: usage_,
- }];
+ if (!mxc) return;
+ const image = {
+ shortcode, mxc, body, usage, info,
+ };
+
+ this.images.set(shortcode, image);
+ if (usage.includes('emoticon')) {
+ this.emoticons.push(image);
+ }
+ if (usage.includes('sticker')) {
+ this.stickers.push(image);
}
- return [];
});
-
- return new ImagePack(displayName, avatar, usage, attribution, images);
}
- constructor(displayName, avatar, usage, attribution, images) {
- this.displayName = displayName;
- this.avatar = avatar;
- this.usage = usage;
- this.attribution = attribution;
- this.images = images;
+ getImages() {
+ return this.images;
}
- // Produce a list of emoji in this image pack
getEmojis() {
- return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1);
+ return this.emoticons;
}
- // Produce a list of stickers in this image pack
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
-//
-// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal
-// image pack.
-//
-// Accepts a reference to a matrix client as the only argument
+function getGlobalImagePacks(mx) {
+ const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
+ if (typeof globalContent !== 'object') return [];
+
+ const { rooms } = globalContent;
+ if (typeof rooms !== 'object') return [];
+
+ const roomIds = Object.keys(rooms);
+
+ const packs = roomIds.flatMap((roomId) => {
+ if (typeof rooms[roomId] !== 'object') return [];
+ const room = mx.getRoom(roomId);
+ if (!room) return [];
+ const stateKeys = Object.keys(rooms[roomId]);
+
+ return stateKeys.map((stateKey) => {
+ const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
+ const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
+ if (pack) {
+ pack.displayName ??= room.name;
+ pack.avatarUrl ??= room.getMxcAvatarUrl();
+ }
+ return pack;
+ }).filter((pack) => pack !== null);
+ });
+
+ return packs;
+}
+
function getUserImagePack(mx) {
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
if (!accountDataEmoji) {
return null;
}
- const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content);
- if (userImagePack) userImagePack.displayName ??= 'Your Emoji';
+ const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
+ userImagePack.displayName ??= 'Personal Emoji';
return userImagePack;
}
-// Produces a list of all of the emoji packs in a room
-//
-// Returns a list of `ImagePack`s. This does not include packs in spaces that contain
-// this room.
-function getPacksInRoom(room) {
- const packs = room.currentState.getStateEvents('im.ponies.room_emotes');
+function getRoomImagePacks(room) {
+ const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
- return packs
- .map((p) => ImagePack.parsePack(p.event.content, room))
- .filter((p) => p !== null);
+ return dataEvents
+ .map((data) => {
+ const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
+ if (pack) {
+ pack.displayName ??= room.name;
+ pack.avatarUrl ??= room.getMxcAvatarUrl();
+ }
+ return pack;
+ })
+ .filter((pack) => pack !== null);
}
-// Produce a list of all image packs which should be shown for a given room
-//
-// This includes packs in that room, the user's personal images, and will eventually
-// include the user's enabled global image packs and space-level packs.
-//
-// This differs from getPacksInRoom, as the former only returns packs that are directly in
-// a room, whereas this function returns all packs which should be shown to the user while
-// they are in this room.
-//
-// Packs will be returned in the order that shortcode conflicts should be resolved, with
-// higher priority packs coming first.
-function getRelevantPacks(room) {
+/**
+ * @param {MatrixClient} mx Provide if you want to include user personal/global pack
+ * @param {Room[]} rooms Provide rooms if you want to include rooms pack
+ * @returns {ImagePack[]} packs
+ */
+function getRelevantPacks(mx, rooms) {
+ const userPack = mx ? getUserImagePack(mx) : [];
+ const globalPacks = mx ? getGlobalImagePacks(mx) : [];
+ const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
+ const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
+
return [].concat(
- getUserImagePack(room.client) ?? [],
- getPacksInRoom(room),
+ userPack ?? [],
+ globalPacks,
+ roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
);
}
-// Returns all user+room emojis and all standard unicode emojis
-//
-// Accepts a reference to a matrix client as the only argument
-//
-// Result is a map from shortcode to the corresponding emoji. If two emoji share a
-// shortcode, only one will be presented, with priority given to custom emoji.
-//
-// Will eventually be expanded to include all emojis revelant to a room and the user
-function getShortcodeToEmoji(room) {
+function getShortcodeToEmoji(mx, rooms) {
const allEmoji = new Map();
emojis.forEach((emoji) => {
- if (emoji.shortcodes.constructor.name === 'Array') {
+ if (Array.isArray(emoji.shortcodes)) {
emoji.shortcodes.forEach((shortcode) => {
allEmoji.set(shortcode, emoji);
});
}
});
- getRelevantPacks(room).reverse()
+ getRelevantPacks(mx, rooms)
.flatMap((pack) => pack.getEmojis())
.forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji);
function getShortcodeToCustomEmoji(room) {
const allEmoji = new Map();
- getRelevantPacks(room).reverse()
+ getRelevantPacks(room.client, [room])
.flatMap((pack) => pack.getEmojis())
.forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji);
return allEmoji;
}
-// Produces a special list of emoji specifically for auto-completion
-//
-// This list contains each emoji once, with all emoji being deduplicated by shortcode.
-// However, the order of the standard emoji will have been preserved, and alternate
-// shortcodes for the standard emoji will not be considered.
-//
-// Standard emoji are guaranteed to be earlier in the list than custom emoji
-function getEmojiForCompletion(room) {
+function getEmojiForCompletion(mx, rooms) {
const allEmoji = new Map();
- getRelevantPacks(room).reverse()
+ getRelevantPacks(mx, rooms)
.flatMap((pack) => pack.getEmojis())
.forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji);
});
- return emojis.filter((e) => !allEmoji.has(e.shortcode))
- .concat(Array.from(allEmoji.values()));
+ return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
}
export {
- getUserImagePack,
+ ImagePack,
+ getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
getShortcodeToEmoji, getShortcodeToCustomEmoji,
getRelevantPacks, getEmojiForCompletion,
};
import RoomEncryption from '../../molecules/room-encryption/RoomEncryption';
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
import RoomMembers from '../../molecules/room-members/RoomMembers';
+import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
+import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
GENERAL: 'General',
SEARCH: 'Search',
MEMBERS: 'Members',
+ EMOJIS: 'Emojis',
PERMISSIONS: 'Permissions',
SECURITY: 'Security',
};
iconSrc: UserIC,
text: tabText.MEMBERS,
disabled: false,
+}, {
+ iconSrc: EmojiIC,
+ text: tabText.EMOJIS,
+ disabled: false,
}, {
iconSrc: ShieldUserIC,
text: tabText.PERMISSIONS,
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
+ {selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
</div>
roomId: PropTypes.string.isRequired,
};
-export {
- RoomSettings as default,
- tabText,
-};
+export default RoomSettings;
+export { tabText };
import Text from '../../atoms/text/Text';
import ScrollView from '../../atoms/scroll/ScrollView';
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
-import { addRecentEmoji } from '../emoji-board/recent';
+import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
const commands = [{
name: 'markdown',
setCmd({ prefix, suggestions: commands });
},
':': () => {
- const emojis = getEmojiForCompletion(mx.getRoom(roomId));
+ const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
+ const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
+ const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
+ const recentEmoji = getRecentEmojis(20);
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
- setCmd({ prefix, suggestions: emojis.slice(26, 46) });
+ setCmd({
+ prefix,
+ suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
+ });
},
'@': () => {
const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({
}
if (myCmd.prefix === '@') {
viewEvent.emit('cmd_fired', {
- replace: myCmd.result.name,
+ replace: `@${myCmd.result.userId}`,
});
}
deactivateCmd();
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings';
-import { openEmojiBoard } from '../../../client/action/navigation';
+import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation';
import { bytesToSize, getEventCords } from '../../../util/common';
import { getUsername } from '../../../util/matrixUtil';
import ScrollView from '../../atoms/scroll/ScrollView';
import { MessageReply } from '../../molecules/message/Message';
+import StickerBoard from '../sticker-board/StickerBoard';
+
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SendIC from '../../../../public/res/ic/outlined/send.svg';
+import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
}
function firedCmd(cmdData) {
const msg = textAreaRef.current.value;
- textAreaRef.current.value = replaceCmdWith(msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '');
+ textAreaRef.current.value = replaceCmdWith(
+ msg,
+ cmdCursorPos,
+ typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
+ );
deactivateCmd();
}
if (replyTo !== null) setReplyTo(null);
};
+ const handleSendSticker = async (data) => {
+ const { mxc: url, body, httpUrl } = data;
+ const info = {};
+
+ const img = new Image();
+ img.src = httpUrl;
+
+ try {
+ const res = await fetch(httpUrl);
+ const blob = await res.blob();
+ info.w = img.width;
+ info.h = img.height;
+ info.mimetype = blob.type;
+ info.size = blob.size;
+ info.thumbnail_info = { ...info };
+ info.thumbnail_url = url;
+ } catch {
+ // send sticker without info
+ }
+
+ mx.sendEvent(roomId, 'm.sticker', {
+ body,
+ url,
+ info,
+ });
+ };
+
function processTyping(msg) {
const isEmptyMsg = msg === '';
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
</div>
<div ref={rightOptionsRef} className="room-input__option-container">
+ <IconButton
+ onClick={(e) => {
+ openReusableContextMenu(
+ 'top',
+ (() => {
+ const cords = getEventCords(e);
+ cords.y -= 20;
+ return cords;
+ })(),
+ (closeMenu) => (
+ <StickerBoard
+ roomId={roomId}
+ onSelect={(data) => {
+ handleSendSticker(data);
+ closeMenu();
+ }}
+ />
+ ),
+ );
+ }}
+ tooltip="Sticker"
+ src={StickerIC}
+ />
<IconButton
onClick={(e) => {
const cords = getEventCords(e);
import SettingTile from '../../molecules/setting-tile/SettingTile';
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
+import { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack';
import ProfileEditor from '../profile-editor/ProfileEditor';
import CrossSigning from './CrossSigning';
import DeviceManage from './DeviceManage';
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
+import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
);
}
+function EmojiSection() {
+ return (
+ <>
+ <div className="settings-emoji__card"><ImagePackUser /></div>
+ <div className="settings-emoji__card"><ImagePackGlobal /></div>
+ </>
+ );
+}
+
function SecuritySection() {
return (
<div className="settings-security">
export const tabText = {
APPEARANCE: 'Appearance',
NOTIFICATIONS: 'Notifications',
+ EMOJI: 'Emoji',
SECURITY: 'Security',
ABOUT: 'About',
};
iconSrc: BellIC,
disabled: false,
render: () => <NotificationsSection />,
+}, {
+ text: tabText.EMOJI,
+ iconSrc: EmojiIC,
+ disabled: false,
+ render: () => <EmojiSection />,
}, {
text: tabText.SECURITY,
iconSrc: LockIC,
.settings-notifications,
.settings-security__card,
.settings-security .device-manage,
-.settings-about__card {
+.settings-about__card,
+.settings-emoji__card {
@extend .settings-window__card;
}
import RoomAliases from '../../molecules/room-aliases/RoomAliases';
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
import RoomMembers from '../../molecules/room-members/RoomMembers';
+import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
+import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import { useForceUpdate } from '../../hooks/useForceUpdate';
const tabText = {
GENERAL: 'General',
MEMBERS: 'Members',
+ EMOJIS: 'Emojis',
PERMISSIONS: 'Permissions',
};
iconSrc: UserIC,
text: tabText.MEMBERS,
disabled: false,
+}, {
+ iconSrc: EmojiIC,
+ text: tabText.EMOJIS,
+ disabled: false,
}, {
iconSrc: ShieldUserIC,
text: tabText.PERMISSIONS,
<div className="space-settings__cards-wrapper">
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
+ {selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
</div>
</div>
--- /dev/null
+/* 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;
--- /dev/null
+@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;
+ }
+}
\ No newline at end of file
}
}
-async function sendReaction(roomId, toEventId, reaction) {
+async function sendReaction(roomId, toEventId, reaction, shortcode) {
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 {
- await mx.sendEvent(roomId, 'm.reaction', {
- 'm.relates_to': {
- event_id: toEventId,
- key: reaction,
- rel_type: 'm.annotation',
- },
- });
+ await mx.sendEvent(roomId, 'm.reaction', content);
} catch (e) {
throw new Error(e);
}
if (prevState === null) {
this.roomList = new RoomList(this.matrixClient);
this.accountData = new AccountData(this.roomList);
- this.roomsInput = new RoomsInput(this.matrixClient);
+ this.roomsInput = new RoomsInput(this.matrixClient, this.roomList);
this.notifications = new Notifications(this.roomList);
this.emit('init_loading_finished');
}
import { math } from 'micromark-extension-math';
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
+import { getImageDimension } from '../../util/common';
import cons from './cons';
import settings from './settings';
-function getImageDimension(file) {
- return new Promise((resolve) => {
- const img = new Image();
- img.onload = async () => {
- resolve({
- w: img.width,
- h: img.height,
- });
- };
- img.src = URL.createObjectURL(file);
- });
-}
function loadVideo(videoFile) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
return newContent;
}
-// Apply formatting to a plain text message
-//
-// This includes inserting any custom emoji that might be relevant, and (only if the
-// user has enabled it in their settings) formatting the message using markdown.
-function formatAndEmojifyText(room, text) {
- const allEmoji = getShortcodeToEmoji(room);
+function formatAndEmojifyText(mx, roomList, roomId, text) {
+ const room = mx.getRoom(roomId);
+ const { userIdsToDisplayNames } = room.currentState;
+ const parentIds = roomList.getAllParentSpaces(roomId);
+ const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
+ const allEmoji = getShortcodeToEmoji(mx, [room, ...parentRooms]);
- // Start by applying markdown formatting (if relevant)
let formattedText;
if (settings.isMarkdown) {
formattedText = getFormattedBody(text);
formattedText = text;
}
- // Check to see if there are any :shortcode-style-tags: in the message
- Array.from(formattedText.matchAll(/\B:([\w-]+):\B/g))
- // Then filter to only the ones corresponding to a valid emoji
- .filter((match) => allEmoji.has(match[1]))
- // Reversing the array ensures that indices are preserved as we start replacing
+ const MXID_REGEX = /\B@\S+:\S+\.\S+[^.,:;?!\s]/g;
+ Array.from(formattedText.matchAll(MXID_REGEX))
+ .filter((mxidMatch) => userIdsToDisplayNames[mxidMatch[0]])
.reverse()
- // Replace each :shortcode: with an <img/> tag
+ .forEach((mxidMatch) => {
+ const tag = `<a href="https://matrix.to/#/${mxidMatch[0]}">${userIdsToDisplayNames[mxidMatch[0]]}</a>`;
+
+ formattedText = formattedText.substr(0, mxidMatch.index)
+ + tag
+ + formattedText.substr(mxidMatch.index + mxidMatch[0].length);
+ });
+
+ const SHORTCODE_REGEX = /\B:([\w-]+):\B/g;
+ Array.from(formattedText.matchAll(SHORTCODE_REGEX))
+ .filter((shortcodeMatch) => allEmoji.has(shortcodeMatch[1]))
+ .reverse() /* Reversing the array ensures that indices are preserved as we start replacing */
.forEach((shortcodeMatch) => {
const emoji = allEmoji.get(shortcodeMatch[1]);
- // Render the tag that will replace the shortcode
let tag;
if (emoji.mxc) {
tag = `<img data-mx-emoticon="" src="${
tag = emoji.unicode;
}
- // Splice the tag into the text
formattedText = formattedText.substr(0, shortcodeMatch.index)
+ tag
+ formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length);
}
class RoomsInput extends EventEmitter {
- constructor(mx) {
+ constructor(mx, roomList) {
super();
this.matrixClient = mx;
+ this.roomList = roomList;
this.roomIdToInput = new Map();
}
// Apply formatting if relevant
const formattedBody = formatAndEmojifyText(
- this.matrixClient.getRoom(roomId),
+ this.matrixClient,
+ this.roomList,
+ roomId,
input.message,
);
if (formattedBody !== input.message) {
// Apply formatting if relevant
const formattedBody = formatAndEmojifyText(
- this.matrixClient.getRoom(roomId),
+ this.matrixClient,
+ this.roomList,
+ roomId,
editedBody,
);
if (formattedBody !== editedBody) {
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;
+ });
+}