Add/delete/rename images to exisitng packs

This commit is contained in:
Ajay Bura 2022-07-30 20:34:38 +05:30 committed by Ajay Bura
parent 7d8c4ce5f1
commit 6ae79e080f
10 changed files with 483 additions and 108 deletions

View file

@ -1,15 +1,58 @@
import React, { useState } from 'react'; import React, {
useState, useMemo, useReducer,
} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './ImagePack.scss'; import './ImagePack.scss';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import { openReusableDialog } from '../../../client/action/navigation';
import { suffixRename } from '../../../util/common';
import Button from '../../atoms/button/Button'; import Button from '../../atoms/button/Button';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Input from '../../atoms/input/Input';
import Checkbox from '../../atoms/button/Checkbox';
import { ImagePack as ImagePackBuilder } from '../../organisms/emoji-board/custom-emoji';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
import ImagePackProfile from './ImagePackProfile'; import ImagePackProfile from './ImagePackProfile';
import ImagePackItem from './ImagePackItem'; import ImagePackItem from './ImagePackItem';
import Checkbox from '../../atoms/button/Checkbox'; import ImagePackUpload from './ImagePackUpload';
import { ImagePack as ImagePackBuilder, getUserImagePack } from '../../organisms/emoji-board/custom-emoji';
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) { function getUsage(usage) {
if (usage.includes('emoticon') && usage.includes('sticker')) return 'both'; if (usage.includes('emoticon') && usage.includes('sticker')) return 'both';
@ -30,46 +73,159 @@ function isGlobalPack(roomId, stateKey) {
return rooms[roomId]?.[stateKey] !== undefined; 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)
), [room, stateKey]);
const sendPackContent = (content) => {
mx.sendStateEvent(roomId, 'im.ponies.room_emotes', content, stateKey);
};
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 handleEditProfile = () => false;
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 {
handleEditProfile,
handleUsageChange,
handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
};
}
function ImagePack({ roomId, stateKey }) { function ImagePack({ roomId, stateKey }) {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
const [viewMore, setViewMore] = useState(false); const [viewMore, setViewMore] = useState(false);
const packEvent = roomId const { pack, sendPackContent } = useRoomImagePack(roomId, stateKey);
? room.currentState.getStateEvents('im.ponies.room_emotes', stateKey)
: mx.getAccountData('im.ponies.user_emotes'); const {
const pack = roomId handleEditProfile,
? ImagePackBuilder.parsePack(packEvent.getId(), packEvent.getContent(), room) handleUsageChange,
: getUserImagePack(mx); handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
} = useImagePackHandles(pack, sendPackContent);
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
return ( return (
<div className="image-pack"> <div className="image-pack">
<ImagePackProfile <ImagePackProfile
avatarUrl={mx.mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc)} avatarUrl={mx.mxcUrlToHttp(pack.avatarUrl)}
displayName={pack.displayName} displayName={pack.displayName ?? 'Unknown'}
attribution={pack.attribution} attribution={pack.attribution}
usage={getUsage(pack.usage)} usage={getUsage(pack.usage)}
onUsageChange={(newUsage) => console.log(newUsage)} onUsageChange={canChange ? handleUsageChange : undefined}
onEdit={() => false} onEdit={canChange ? handleEditProfile : undefined}
/> />
<div> { canChange && (
<div className="image-pack__header"> <ImagePackUpload onUpload={handleAddItem} />
<Text variant="b3">Image</Text> )}
<Text variant="b3">Shortcode</Text> { images.length === 0 ? null : (
<Text variant="b3">Usage</Text> <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> </div>
{([...pack.images].slice(0, viewMore ? pack.images.size : 2)).map(([shortcode, image]) => ( )}
<ImagePackItem
key={shortcode}
url={mx.mxcUrlToHttp(image.mxc)}
shortcode={shortcode}
usage={getUsage(image.usage)}
onUsageChange={() => false}
onDelete={() => false}
onRename={() => false}
/>
))}
</div>
{pack.images.size > 2 && ( {pack.images.size > 2 && (
<div className="image-pack__footer"> <div className="image-pack__footer">
<Button onClick={() => setViewMore(!viewMore)}> <Button onClick={() => setViewMore(!viewMore)}>
@ -81,27 +237,20 @@ function ImagePack({ roomId, stateKey }) {
</Button> </Button>
</div> </div>
)} )}
{ roomId && ( <div className="image-pack__global">
<div className="image-pack__global"> <Checkbox variant="positive" isActive={isGlobalPack(roomId, stateKey)} />
<Checkbox variant="positive" isActive={isGlobalPack(roomId, stateKey)} /> <div>
<div> <Text variant="b2">Use globally</Text>
<Text variant="b2">Use globally</Text> <Text variant="b3">Add this pack to your account to use in all rooms.</Text>
<Text variant="b3">Add this pack to your account to use in all rooms.</Text>
</div>
</div> </div>
)} </div>
</div> </div>
); );
} }
ImagePack.defaultProps = {
roomId: null,
stateKey: null,
};
ImagePack.propTypes = { ImagePack.propTypes = {
roomId: PropTypes.string, roomId: PropTypes.string.isRequired,
stateKey: PropTypes.string, stateKey: PropTypes.string.isRequired,
}; };
export default ImagePack; export default ImagePack;

View file

@ -19,7 +19,9 @@
&__footer { &__footer {
padding: var(--sp-normal); padding: var(--sp-normal);
display: flex; display: flex;
gap: var(--sp-tight);
} }
&__global { &__global {
padding: var(--sp-normal); padding: var(--sp-normal);
padding-top: var(--sp-tight); padding-top: var(--sp-tight);

View file

@ -27,7 +27,7 @@ function ImagePackItem({
<ImagePackUsageSelector <ImagePackUsageSelector
usage={usage} usage={usage}
onSelect={(newUsage) => { onSelect={(newUsage) => {
onUsageChange(newUsage); onUsageChange(shortcode, newUsage);
closeMenu(); closeMenu();
}} }}
/> />
@ -43,8 +43,8 @@ function ImagePackItem({
</div> </div>
<div className="image-pack-item__usage"> <div className="image-pack-item__usage">
<div className="image-pack-item__btn"> <div className="image-pack-item__btn">
{onRename && <IconButton tooltip="Rename" size="extra-small" src={PencilIC} onClick={onRename} />} {onRename && <IconButton tooltip="Rename" size="extra-small" src={PencilIC} onClick={() => onRename(shortcode)} />}
{onDelete && <IconButton tooltip="Delete" size="extra-small" src={BinIC} onClick={onDelete} />} {onDelete && <IconButton tooltip="Delete" size="extra-small" src={BinIC} onClick={() => onDelete(shortcode)} />}
</div> </div>
<Button onClick={onUsageChange ? handleUsageSelect : undefined}> <Button onClick={onUsageChange ? handleUsageSelect : undefined}>
{onUsageChange && <RawIcon src={ChevronBottomIC} size="extra-small" />} {onUsageChange && <RawIcon src={ChevronBottomIC} size="extra-small" />}

View file

@ -35,7 +35,7 @@ function ImagePackProfile({
return ( return (
<div className="image-pack-profile"> <div className="image-pack-profile">
<Avatar text={displayName} bgColor="blue" imageSrc={avatarUrl} size="normal" /> {avatarUrl && <Avatar text={displayName} bgColor="blue" imageSrc={avatarUrl} size="normal" />}
<div className="image-pack-profile__content"> <div className="image-pack-profile__content">
<div> <div>
<Text>{displayName}</Text> <Text>{displayName}</Text>

View file

@ -0,0 +1,72 @@
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);
};
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;

View 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;
}
}
}

View file

@ -257,7 +257,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"
@ -297,7 +297,7 @@ function EmojiBoard({ onSelect, searchRef }) {
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
/> />

View file

@ -8,20 +8,38 @@ class ImagePack {
return null; return null;
} }
const pack = packContent.pack ?? {}; return new ImagePack(eventId, packContent, room);
}
const displayName = pack.display_name ?? room?.name ?? undefined; constructor(eventId, content, room) {
const avatarUrl = pack.avatar_url ?? room?.getMxcAvatarUrl() ?? undefined; this.id = eventId;
const packUsage = pack.usage ?? ['emoticon', 'sticker']; this.content = JSON.parse(JSON.stringify(content));
const { attribution } = pack;
const images = new Map();
const emoticons = [];
const stickers = [];
Object.entries(packContent.images).forEach(([shortcode, data]) => { this.displayName = room?.name ?? undefined;
this.avatarUrl = room?.getMxcAvatarUrl() ?? undefined;
this.applyPack(content);
this.applyImages(content);
}
applyPack(content) {
const pack = content.pack ?? {};
this.displayName = pack.display_name ?? this.displayName;
this.avatarUrl = pack.avatar_url ?? this.avatarUrl;
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 ?? packUsage; const usage = data.usage ?? this.usage;
const { info } = data; const { info } = data;
if (!mxc) return; if (!mxc) return;
@ -29,44 +47,14 @@ class ImagePack {
shortcode, mxc, body, usage, info, shortcode, mxc, body, usage, info,
}; };
images.set(shortcode, image); this.images.set(shortcode, image);
if (usage.includes('emoticon')) { if (usage.includes('emoticon')) {
emoticons.push(image); this.emoticons.push(image);
} }
if (usage.includes('sticker')) { if (usage.includes('sticker')) {
stickers.push(image); this.stickers.push(image);
} }
}); });
return new ImagePack(eventId, {
displayName,
avatarUrl,
usage: packUsage,
attribution,
images,
emoticons,
stickers,
});
}
constructor(id, {
displayName,
avatarUrl,
usage,
attribution,
images,
emoticons,
stickers,
}) {
this.id = id;
this.displayName = displayName;
this.avatarUrl = avatarUrl;
this.usage = usage;
this.attribution = attribution;
this.images = images;
this.emoticons = emoticons;
this.stickers = stickers;
} }
getImages() { getImages() {
@ -80,6 +68,80 @@ class ImagePack {
getStickers() { getStickers() {
return this.stickers; return this.stickers;
} }
getContent() {
return this.content;
}
_updatePackProperty(property, value) {
if (this.content.pack === undefined) {
this.content.pack = {};
}
this.content.pack[property] = value;
this.applyPack(this.content);
}
setAvatarUrl(avatarUrl) {
this._updatePackProperty('avatar_url', avatarUrl);
}
setDisplayName(displayName) {
this._updatePackProperty('display_name', displayName);
}
setAttribution(attribution) {
this._updatePackProperty('attribution', attribution);
}
setUsage(usage) {
this._updatePackProperty('usage', usage);
}
addImage(key, imgContent) {
this.content.images = {
[key]: imgContent,
...this.content.images,
};
this.applyImages(this.content);
}
removeImage(key) {
if (this.content.images[key] === undefined) return;
delete this.content.images[key];
this.applyImages(this.content);
}
updateImageKey(key, newKey) {
if (this.content.images[key] === undefined) return;
const copyImages = {};
Object.keys(this.content.images).forEach((imgKey) => {
copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
});
this.content.images = copyImages;
this.applyImages(this.content);
}
_updateImageProperty(key, property, value) {
if (this.content.images[key] === undefined) return;
this.content.images[key][property] = value;
this.applyImages(this.content);
}
setImageUrl(key, url) {
this._updateImageProperty(key, 'url', url);
}
setImageBody(key, body) {
this._updateImageProperty(key, 'body', body);
}
setImageInfo(key, info) {
this._updateImageProperty(key, 'info', info);
}
setImageUsage(key, usage) {
this._updateImageProperty(key, 'usage', usage);
}
} }
function getGlobalImagePacks(mx) { function getGlobalImagePacks(mx) {
@ -112,7 +174,6 @@ function getUserImagePack(mx) {
} }
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content); const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
if (userImagePack) userImagePack.displayName ??= 'Personal Emoji';
return userImagePack; return userImagePack;
} }

View file

@ -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');

View file

@ -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;
});
}