diff --git a/src/app/molecules/image-pack/ImagePack.jsx b/src/app/molecules/image-pack/ImagePack.jsx index 8cd45a5d..32253497 100644 --- a/src/app/molecules/image-pack/ImagePack.jsx +++ b/src/app/molecules/image-pack/ImagePack.jsx @@ -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( + Rename, + (requestClose) => ( +
+
{ + e.preventDefault(); + const sc = e.target.shortcode.value; + if (sc.trim() === '') return; + isCompleted = true; + resolve(sc.trim()); + requestClose(); + }} + > + +
+ + +
+ ), + () => { + 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 (
console.log(newUsage)} - onEdit={() => false} + onUsageChange={canChange ? handleUsageChange : undefined} + onEdit={canChange ? handleEditProfile : undefined} /> -
-
- Image - Shortcode - Usage + { canChange && ( + + )} + { images.length === 0 ? null : ( +
+
+ Image + Shortcode + Usage +
+ {images.map(([shortcode, image]) => ( + + ))}
- {([...pack.images].slice(0, viewMore ? pack.images.size : 2)).map(([shortcode, image]) => ( - false} - onDelete={() => false} - onRename={() => false} - /> - ))} -
+ )} {pack.images.size > 2 && (
)} - { roomId && ( -
- -
- Use globally - Add this pack to your account to use in all rooms. -
+
+ +
+ Use globally + Add this pack to your account to use in all rooms.
- )} +
); } -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; diff --git a/src/app/molecules/image-pack/ImagePack.scss b/src/app/molecules/image-pack/ImagePack.scss index b4becfbf..7c62ffc9 100644 --- a/src/app/molecules/image-pack/ImagePack.scss +++ b/src/app/molecules/image-pack/ImagePack.scss @@ -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); @@ -27,4 +29,4 @@ align-items: center; gap: var(--sp-normal); } -} \ No newline at end of file +} diff --git a/src/app/molecules/image-pack/ImagePackItem.jsx b/src/app/molecules/image-pack/ImagePackItem.jsx index d0f531b8..27436793 100644 --- a/src/app/molecules/image-pack/ImagePackItem.jsx +++ b/src/app/molecules/image-pack/ImagePackItem.jsx @@ -27,7 +27,7 @@ function ImagePackItem({ { - onUsageChange(newUsage); + onUsageChange(shortcode, newUsage); closeMenu(); }} /> @@ -43,8 +43,8 @@ function ImagePackItem({
- {onRename && } - {onDelete && } + {onRename && onRename(shortcode)} />} + {onDelete && onDelete(shortcode)} />}
+ } + + + + ); +} +ImagePackUpload.propTypes = { + onUpload: PropTypes.func.isRequired, +}; + +export default ImagePackUpload; diff --git a/src/app/molecules/image-pack/ImagePackUpload.scss b/src/app/molecules/image-pack/ImagePackUpload.scss new file mode 100644 index 00000000..75b57ed2 --- /dev/null +++ b/src/app/molecules/image-pack/ImagePackUpload.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/src/app/organisms/emoji-board/EmojiBoard.jsx b/src/app/organisms/emoji-board/EmojiBoard.jsx index 1e8fce8d..ba8ab213 100644 --- a/src/app/organisms/emoji-board/EmojiBoard.jsx +++ b/src/app/organisms/emoji-board/EmojiBoard.jsx @@ -257,7 +257,7 @@ function EmojiBoard({ onSelect, searchRef }) { { availableEmojis.map((pack) => ( openGroup(recentOffset + pack.packIndex)} src={src} key={pack.packIndex} - tooltip={pack.displayName} + tooltip={pack.displayName ?? 'Unknown'} tooltipPlacement="right" isImage /> diff --git a/src/app/organisms/emoji-board/custom-emoji.js b/src/app/organisms/emoji-board/custom-emoji.js index 9ba7c5e7..026e08b6 100644 --- a/src/app/organisms/emoji-board/custom-emoji.js +++ b/src/app/organisms/emoji-board/custom-emoji.js @@ -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; } diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 882c7bc0..62bddd50 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -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'); diff --git a/src/util/common.js b/src/util/common.js index 3d5383ad..57891a9d 100644 --- a/src/util/common.js +++ b/src/util/common.js @@ -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; + }); +}