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 './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 { ImagePack as ImagePackBuilder } from '../../organisms/emoji-board/custom-emoji';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
import ImagePackProfile from './ImagePackProfile';
import ImagePackItem from './ImagePackItem';
import Checkbox from '../../atoms/button/Checkbox';
import { ImagePack as ImagePackBuilder, getUserImagePack } from '../../organisms/emoji-board/custom-emoji';
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';
@ -30,46 +73,159 @@ function isGlobalPack(roomId, stateKey) {
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 }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const [viewMore, setViewMore] = useState(false);
const packEvent = roomId
? room.currentState.getStateEvents('im.ponies.room_emotes', stateKey)
: mx.getAccountData('im.ponies.user_emotes');
const pack = roomId
? ImagePackBuilder.parsePack(packEvent.getId(), packEvent.getContent(), room)
: getUserImagePack(mx);
const { pack, sendPackContent } = useRoomImagePack(roomId, stateKey);
const {
handleEditProfile,
handleUsageChange,
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 (
<div className="image-pack">
<ImagePackProfile
avatarUrl={mx.mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc)}
displayName={pack.displayName}
avatarUrl={mx.mxcUrlToHttp(pack.avatarUrl)}
displayName={pack.displayName ?? 'Unknown'}
attribution={pack.attribution}
usage={getUsage(pack.usage)}
onUsageChange={(newUsage) => console.log(newUsage)}
onEdit={() => false}
onUsageChange={canChange ? handleUsageChange : undefined}
onEdit={canChange ? handleEditProfile : undefined}
/>
{ 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>
{([...pack.images].slice(0, viewMore ? pack.images.size : 2)).map(([shortcode, image]) => (
{images.map(([shortcode, image]) => (
<ImagePackItem
key={shortcode}
url={mx.mxcUrlToHttp(image.mxc)}
shortcode={shortcode}
usage={getUsage(image.usage)}
onUsageChange={() => false}
onDelete={() => false}
onRename={() => false}
onUsageChange={canChange ? handleUsageItem : undefined}
onDelete={canChange ? handleDeleteItem : undefined}
onRename={canChange ? handleRenameItem : undefined}
/>
))}
</div>
)}
{pack.images.size > 2 && (
<div className="image-pack__footer">
<Button onClick={() => setViewMore(!viewMore)}>
@ -81,7 +237,6 @@ function ImagePack({ roomId, stateKey }) {
</Button>
</div>
)}
{ roomId && (
<div className="image-pack__global">
<Checkbox variant="positive" isActive={isGlobalPack(roomId, stateKey)} />
<div>
@ -89,19 +244,13 @@ function ImagePack({ roomId, stateKey }) {
<Text variant="b3">Add this pack to your account to use in all rooms.</Text>
</div>
</div>
)}
</div>
);
}
ImagePack.defaultProps = {
roomId: null,
stateKey: null,
};
ImagePack.propTypes = {
roomId: PropTypes.string,
stateKey: PropTypes.string,
roomId: PropTypes.string.isRequired,
stateKey: PropTypes.string.isRequired,
};
export default ImagePack;

View file

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

View file

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

View file

@ -35,7 +35,7 @@ function ImagePackProfile({
return (
<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>
<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) => (
<EmojiGroup
name={pack.displayName}
name={pack.displayName ?? 'Unknown'}
key={pack.packIndex}
groupEmojis={pack.getEmojis()}
className="custom-emoji-group"
@ -297,7 +297,7 @@ function EmojiBoard({ onSelect, searchRef }) {
onClick={() => openGroup(recentOffset + pack.packIndex)}
src={src}
key={pack.packIndex}
tooltip={pack.displayName}
tooltip={pack.displayName ?? 'Unknown'}
tooltipPlacement="right"
isImage
/>

View file

@ -8,20 +8,38 @@ class ImagePack {
return null;
}
const pack = packContent.pack ?? {};
return new ImagePack(eventId, packContent, room);
}
const displayName = pack.display_name ?? room?.name ?? undefined;
const avatarUrl = pack.avatar_url ?? room?.getMxcAvatarUrl() ?? undefined;
const packUsage = pack.usage ?? ['emoticon', 'sticker'];
const { attribution } = pack;
const images = new Map();
const emoticons = [];
const stickers = [];
constructor(eventId, content, room) {
this.id = eventId;
this.content = JSON.parse(JSON.stringify(content));
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 body = data.body ?? shortcode;
const usage = data.usage ?? packUsage;
const usage = data.usage ?? this.usage;
const { info } = data;
if (!mxc) return;
@ -29,44 +47,14 @@ class ImagePack {
shortcode, mxc, body, usage, info,
};
images.set(shortcode, image);
this.images.set(shortcode, image);
if (usage.includes('emoticon')) {
emoticons.push(image);
this.emoticons.push(image);
}
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() {
@ -80,6 +68,80 @@ class ImagePack {
getStickers() {
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) {
@ -112,7 +174,6 @@ function getUserImagePack(mx) {
}
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
if (userImagePack) userImagePack.displayName ??= 'Personal Emoji';
return userImagePack;
}

View file

@ -5,21 +5,10 @@ import encrypt from 'browser-encrypt-attachment';
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');

View file

@ -132,3 +132,62 @@ export function copyToClipboard(text) {
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;
});
}