From edace3221384fa1d254d3d92d444177eb1813227 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 6 Aug 2022 09:04:23 +0530 Subject: [PATCH] Custom emoji & Sticker support (#686) * Remove comments * Show custom emoji first in suggestions * Show global image packs in emoji picker * Display emoji and sticker in room settings * Fix some pack not visible in emojiboard * WIP * Add/delete/rename images to exisitng packs * Change pack avatar, name & attribution * Add checkbox to make pack global * Bug fix * Create or delete pack * Add personal emoji in settings * Show global pack selector in settings * Show space emoji in emojiboard * Send custom emoji reaction as mxc * Render stickers as stickers * Fix sticker jump bug * Fix reaction width * Fix stretched custom emoji * Fix sending space emoji in message * Remove unnessesary comments * Send user pills * Fix pill generating regex * Add support for sending stickers --- public/res/ic/outlined/sticker.svg | 4 + src/app/atoms/button/Button.scss | 8 +- src/app/molecules/image-pack/ImagePack.jsx | 469 ++++++++++++++++++ src/app/molecules/image-pack/ImagePack.scss | 47 ++ .../molecules/image-pack/ImagePackItem.jsx | 76 +++ .../molecules/image-pack/ImagePackItem.scss | 43 ++ .../molecules/image-pack/ImagePackProfile.jsx | 125 +++++ .../image-pack/ImagePackProfile.scss | 37 ++ .../molecules/image-pack/ImagePackUpload.jsx | 73 +++ .../molecules/image-pack/ImagePackUpload.scss | 43 ++ .../image-pack/ImagePackUsageSelector.jsx | 41 ++ .../molecules/image-upload/ImageUpload.jsx | 14 +- src/app/molecules/media/Media.jsx | 46 +- src/app/molecules/media/Media.scss | 9 + src/app/molecules/message/Message.jsx | 54 +- src/app/molecules/message/Message.scss | 1 - src/app/molecules/room-emojis/RoomEmojis.jsx | 130 +++++ src/app/molecules/room-emojis/RoomEmojis.scss | 29 ++ src/app/organisms/emoji-board/EmojiBoard.jsx | 42 +- src/app/organisms/emoji-board/EmojiBoard.scss | 2 + src/app/organisms/emoji-board/custom-emoji.js | 286 +++++++---- src/app/organisms/room/RoomSettings.jsx | 14 +- src/app/organisms/room/RoomViewCmdBar.jsx | 14 +- src/app/organisms/room/RoomViewInput.jsx | 61 ++- src/app/organisms/settings/Settings.jsx | 17 + src/app/organisms/settings/Settings.scss | 3 +- .../space-settings/SpaceSettings.jsx | 8 + .../organisms/sticker-board/StickerBoard.jsx | 88 ++++ .../organisms/sticker-board/StickerBoard.scss | 60 +++ src/client/action/roomTimeline.js | 19 +- src/client/initMatrix.js | 2 +- src/client/state/RoomsInput.js | 60 +-- src/util/common.js | 59 +++ 33 files changed, 1781 insertions(+), 203 deletions(-) create mode 100644 public/res/ic/outlined/sticker.svg create mode 100644 src/app/molecules/image-pack/ImagePack.jsx create mode 100644 src/app/molecules/image-pack/ImagePack.scss create mode 100644 src/app/molecules/image-pack/ImagePackItem.jsx create mode 100644 src/app/molecules/image-pack/ImagePackItem.scss create mode 100644 src/app/molecules/image-pack/ImagePackProfile.jsx create mode 100644 src/app/molecules/image-pack/ImagePackProfile.scss create mode 100644 src/app/molecules/image-pack/ImagePackUpload.jsx create mode 100644 src/app/molecules/image-pack/ImagePackUpload.scss create mode 100644 src/app/molecules/image-pack/ImagePackUsageSelector.jsx create mode 100644 src/app/molecules/room-emojis/RoomEmojis.jsx create mode 100644 src/app/molecules/room-emojis/RoomEmojis.scss create mode 100644 src/app/organisms/sticker-board/StickerBoard.jsx create mode 100644 src/app/organisms/sticker-board/StickerBoard.scss diff --git a/public/res/ic/outlined/sticker.svg b/public/res/ic/outlined/sticker.svg new file mode 100644 index 0000000..bc486e5 --- /dev/null +++ b/public/res/ic/outlined/sticker.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/atoms/button/Button.scss b/src/app/atoms/button/Button.scss index 7b12195..e1a01bb 100644 --- a/src/app/atoms/button/Button.scss +++ b/src/app/atoms/button/Button.scss @@ -26,10 +26,10 @@ &--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; } } diff --git a/src/app/molecules/image-pack/ImagePack.jsx b/src/app/molecules/image-pack/ImagePack.jsx new file mode 100644 index 0000000..725291d --- /dev/null +++ b/src/app/molecules/image-pack/ImagePack.jsx @@ -0,0 +1,469 @@ +import React, { + useState, useMemo, useReducer, useEffect, +} from 'react'; +import PropTypes from 'prop-types'; +import './ImagePack.scss'; + +import initMatrix from '../../../client/initMatrix'; +import { openReusableDialog } from '../../../client/action/navigation'; +import { suffixRename } from '../../../util/common'; + +import Button from '../../atoms/button/Button'; +import Text from '../../atoms/text/Text'; +import Input from '../../atoms/input/Input'; +import Checkbox from '../../atoms/button/Checkbox'; +import { MenuHeader } from '../../atoms/context-menu/ContextMenu'; + +import { ImagePack as ImagePackBuilder } from '../../organisms/emoji-board/custom-emoji'; +import { confirmDialog } from '../confirm-dialog/ConfirmDialog'; +import ImagePackProfile from './ImagePackProfile'; +import ImagePackItem from './ImagePackItem'; +import ImagePackUpload from './ImagePackUpload'; + +const renameImagePackItem = (shortcode) => new Promise((resolve) => { + let isCompleted = false; + + openReusableDialog( + 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'; + 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 ( +
+ + { canChange && ( + + )} + { images.length === 0 ? null : ( +
+
+ Image + Shortcode + Usage +
+ {images.map(([shortcode, image]) => ( + + ))} +
+ )} + {(pack.images.size > 2 || handlePackDelete) && ( +
+ {pack.images.size > 2 && ( + + )} + { handlePackDelete && } +
+ )} +
+ +
+ Use globally + Add this pack to your account to use in all rooms. +
+
+
+ ); +} + +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 ( +
+ + + { images.length === 0 ? null : ( +
+
+ Image + Shortcode + Usage +
+ {images.map(([shortcode, image]) => ( + + ))} +
+ )} + {(pack.images.size > 2) && ( +
+ +
+ )} +
+ ); +} + +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 ( +
+ Global packs +
+ { + 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 ( +
+ handleChange(roomId, stateKey)} isActive /> +
+ {pack.displayName ?? 'Unknown'} + {room.name} +
+
+ ); + }) + ); + }) + :
No global packs
+ } +
+
+ ); +} + +export default ImagePack; + +export { ImagePackUser, ImagePackGlobal }; diff --git a/src/app/molecules/image-pack/ImagePack.scss b/src/app/molecules/image-pack/ImagePack.scss new file mode 100644 index 0000000..91d6a18 --- /dev/null +++ b/src/app/molecules/image-pack/ImagePack.scss @@ -0,0 +1,47 @@ +@use '../../partials/flex'; + +.image-pack { + &-item { + border-top: 1px solid var(--bg-surface-border); + } + + &__header { + padding: var(--sp-extra-tight) var(--sp-normal); + display: flex; + align-items: center; + gap: var(--sp-normal); + + & > *:nth-child(2) { + @extend .cp-fx__item-one; + } + } + + &__footer { + padding: var(--sp-normal); + display: flex; + justify-content: space-between; + gap: var(--sp-tight); + } + + &__global { + padding: var(--sp-normal); + padding-top: var(--sp-tight); + display: flex; + align-items: center; + gap: var(--sp-normal); + } +} + +.image-pack-global { + &__empty { + text-align: center; + padding: var(--sp-extra-loose) var(--sp-normal); + } + & .image-pack__global { + padding: 0 var(--sp-normal); + padding-bottom: var(--sp-normal); + &:first-child { + padding-top: var(--sp-normal); + } + } +} diff --git a/src/app/molecules/image-pack/ImagePackItem.jsx b/src/app/molecules/image-pack/ImagePackItem.jsx new file mode 100644 index 0000000..2743679 --- /dev/null +++ b/src/app/molecules/image-pack/ImagePackItem.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './ImagePackItem.scss'; + +import { openReusableContextMenu } from '../../../client/action/navigation'; +import { getEventCords } from '../../../util/common'; + +import Avatar from '../../atoms/avatar/Avatar'; +import Text from '../../atoms/text/Text'; +import Button from '../../atoms/button/Button'; +import RawIcon from '../../atoms/system-icons/RawIcon'; +import IconButton from '../../atoms/button/IconButton'; +import ImagePackUsageSelector from './ImagePackUsageSelector'; + +import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg'; +import PencilIC from '../../../../public/res/ic/outlined/pencil.svg'; +import BinIC from '../../../../public/res/ic/outlined/bin.svg'; + +function ImagePackItem({ + url, shortcode, usage, onUsageChange, onDelete, onRename, +}) { + const handleUsageSelect = (event) => { + openReusableContextMenu( + 'bottom', + getEventCords(event, '.btn-surface'), + (closeMenu) => ( + { + onUsageChange(shortcode, newUsage); + closeMenu(); + }} + /> + ), + ); + }; + + return ( +
+ +
+ {shortcode} +
+
+
+ {onRename && onRename(shortcode)} />} + {onDelete && onDelete(shortcode)} />} +
+ +
+
+ ); +} + +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; diff --git a/src/app/molecules/image-pack/ImagePackItem.scss b/src/app/molecules/image-pack/ImagePackItem.scss new file mode 100644 index 0000000..ab1be3a --- /dev/null +++ b/src/app/molecules/image-pack/ImagePackItem.scss @@ -0,0 +1,43 @@ +@use '../../partials/flex'; +@use '../../partials/dir'; + +.image-pack-item { + margin: 0 var(--sp-normal); + padding: var(--sp-tight) 0; + display: flex; + align-items: center; + gap: var(--sp-normal); + + & .avatar-container img { + object-fit: contain; + border-radius: 0; + } + + &__content { + @extend .cp-fx__item-one; + } + + &__usage { + display: flex; + gap: var(--sp-ultra-tight); + & button { + padding: 6px; + } + & > button.btn-surface { + padding: 6px var(--sp-tight); + min-width: 0; + @include dir.side(margin, var(--sp-ultra-tight), 0); + } + } + + &__btn { + display: none; + } + &:hover, + &:focus-within { + .image-pack-item__btn { + display: flex; + gap: var(--sp-ultra-tight); + } + } +} \ No newline at end of file diff --git a/src/app/molecules/image-pack/ImagePackProfile.jsx b/src/app/molecules/image-pack/ImagePackProfile.jsx new file mode 100644 index 0000000..b639936 --- /dev/null +++ b/src/app/molecules/image-pack/ImagePackProfile.jsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import './ImagePackProfile.scss'; + +import { openReusableContextMenu } from '../../../client/action/navigation'; +import { getEventCords } from '../../../util/common'; + +import Text from '../../atoms/text/Text'; +import Avatar from '../../atoms/avatar/Avatar'; +import Button from '../../atoms/button/Button'; +import IconButton from '../../atoms/button/IconButton'; +import Input from '../../atoms/input/Input'; +import ImageUpload from '../image-upload/ImageUpload'; +import ImagePackUsageSelector from './ImagePackUsageSelector'; + +import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg'; +import PencilIC from '../../../../public/res/ic/outlined/pencil.svg'; + +function ImagePackProfile({ + avatarUrl, displayName, attribution, usage, + onUsageChange, onAvatarChange, onEditProfile, +}) { + const [isEdit, setIsEdit] = useState(false); + + const handleSubmit = (e) => { + e.preventDefault(); + + const { nameInput, attributionInput } = e.target; + const name = nameInput.value.trim() || undefined; + const att = attributionInput.value.trim() || undefined; + + onEditProfile(name, att); + setIsEdit(false); + }; + + const handleUsageSelect = (event) => { + openReusableContextMenu( + 'bottom', + getEventCords(event, '.btn-surface'), + (closeMenu) => ( + { + onUsageChange(newUsage); + closeMenu(); + }} + /> + ), + ); + }; + + return ( +
+ { + onAvatarChange + ? ( + onAvatarChange(undefined)} + /> + ) + : + } +
+ { + isEdit + ? ( +
+ + +
+ + +
+
+ ) : ( + <> +
+ {displayName} + {onEditProfile && setIsEdit(true)} src={PencilIC} tooltip="Edit" />} +
+ {attribution && {attribution}} + + ) + } +
+
+ Pack usage + +
+
+ ); +} + +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; diff --git a/src/app/molecules/image-pack/ImagePackProfile.scss b/src/app/molecules/image-pack/ImagePackProfile.scss new file mode 100644 index 0000000..d21212f --- /dev/null +++ b/src/app/molecules/image-pack/ImagePackProfile.scss @@ -0,0 +1,37 @@ +@use '../../partials/flex'; + +.image-pack-profile { + padding: var(--sp-normal); + display: flex; + align-items: flex-start; + gap: var(--sp-tight); + + &__content { + @extend .cp-fx__item-one; + + & > div:first-child { + display: flex; + align-items: center; + gap: var(--sp-extra-tight); + + & .ic-btn { + padding: var(--sp-ultra-tight); + } + } + & > form { + display: flex; + flex-direction: column; + gap: var(--sp-extra-tight); + & > div:last-child { + margin: var(--sp-extra-tight) 0; + display: flex; + gap: var(--sp-tight); + } + } + } + &__usage { + & > *:first-child { + margin-bottom: var(--sp-ultra-tight); + } + } +} \ No newline at end of file diff --git a/src/app/molecules/image-pack/ImagePackUpload.jsx b/src/app/molecules/image-pack/ImagePackUpload.jsx new file mode 100644 index 0000000..9358856 --- /dev/null +++ b/src/app/molecules/image-pack/ImagePackUpload.jsx @@ -0,0 +1,73 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './ImagePackUpload.scss'; + +import initMatrix from '../../../client/initMatrix'; +import { scaleDownImage } from '../../../util/common'; + +import Text from '../../atoms/text/Text'; +import Button from '../../atoms/button/Button'; +import Input from '../../atoms/input/Input'; +import IconButton from '../../atoms/button/IconButton'; +import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; + +function ImagePackUpload({ onUpload }) { + const mx = initMatrix.matrixClient; + const inputRef = useRef(null); + const shortcodeRef = useRef(null); + const [imgFile, setImgFile] = useState(null); + const [progress, setProgress] = useState(false); + + const handleSubmit = async (evt) => { + evt.preventDefault(); + if (!imgFile) return; + const { shortcodeInput } = evt.target; + const shortcode = shortcodeInput.value.trim(); + if (shortcode === '') return; + + setProgress(true); + const image = await scaleDownImage(imgFile, 512, 512); + const url = await mx.uploadContent(image, { + onlyContentUri: true, + }); + + onUpload(shortcode, url); + setProgress(false); + setImgFile(null); + shortcodeRef.current.value = ''; + }; + + const handleFileChange = (evt) => { + const img = evt.target.files[0]; + if (!img) return; + setImgFile(img); + shortcodeRef.current.focus(); + }; + const handleRemove = () => { + setImgFile(null); + inputRef.current.value = null; + }; + + return ( +
+ + { + imgFile + ? ( +
+ + {imgFile.name} +
+ ) + : + } + + +
+ ); +} +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 0000000..75b57ed --- /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/molecules/image-pack/ImagePackUsageSelector.jsx b/src/app/molecules/image-pack/ImagePackUsageSelector.jsx new file mode 100644 index 0000000..279b381 --- /dev/null +++ b/src/app/molecules/image-pack/ImagePackUsageSelector.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu'; +import CheckIC from '../../../../public/res/ic/outlined/check.svg'; + +function ImagePackUsageSelector({ usage, onSelect }) { + return ( +
+ Usage + onSelect('emoticon')} + > + Emoji + + onSelect('sticker')} + > + Sticker + + onSelect('both')} + > + Both + +
+ ); +} + +ImagePackUsageSelector.propTypes = { + usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired, + onSelect: PropTypes.func.isRequired, +}; + +export default ImagePackUsageSelector; diff --git a/src/app/molecules/image-upload/ImageUpload.jsx b/src/app/molecules/image-upload/ImageUpload.jsx index 69564aa..137d23b 100644 --- a/src/app/molecules/image-upload/ImageUpload.jsx +++ b/src/app/molecules/image-upload/ImageUpload.jsx @@ -7,9 +7,13 @@ import initMatrix from '../../../client/initMatrix'; 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); @@ -50,10 +54,14 @@ function ImageUpload({ imageSrc={imageSrc} text={text} bgColor={bgColor} - size="large" + size={size} />
- {uploadPromise === null && Upload} + {uploadPromise === null && ( + size === 'large' + ? Upload + : + )} {uploadPromise !== null && }
@@ -75,6 +83,7 @@ ImageUpload.defaultProps = { text: null, bgColor: 'transparent', imageSrc: null, + size: 'large', }; ImageUpload.propTypes = { @@ -83,6 +92,7 @@ ImageUpload.propTypes = { imageSrc: PropTypes.string, onUpload: PropTypes.func.isRequired, onRequestRemove: PropTypes.func.isRequired, + size: PropTypes.oneOf(['large', 'normal']), }; export default ImageUpload; diff --git a/src/app/molecules/media/Media.jsx b/src/app/molecules/media/Media.jsx index 341dcb0..c4b4a17 100644 --- a/src/app/molecules/media/Media.jsx +++ b/src/app/molecules/media/Media.jsx @@ -69,9 +69,8 @@ async function getUrl(link, type, decryptData) { } } -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; } @@ -196,6 +195,45 @@ Image.propTypes = { 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 ( +
+ { url !== null && {name}} +
+ ); +} +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, }) { @@ -315,5 +353,5 @@ Video.propTypes = { }; export { - File, Image, Audio, Video, + File, Image, Sticker, Audio, Video, }; diff --git a/src/app/molecules/media/Media.scss b/src/app/molecules/media/Media.scss index 7b9d6f7..16cf8f7 100644 --- a/src/app/molecules/media/Media.scss +++ b/src/app/molecules/media/Media.scss @@ -42,6 +42,15 @@ background-size: cover; } +.sticker-container { + display: inline-flex; + max-width: 128px; + width: 100%; + & img { + width: 100% !important; + } +} + .image-container { & img { max-width: unset !important; diff --git a/src/app/molecules/message/Message.jsx b/src/app/molecules/message/Message.jsx index c1370ec..49337bd 100644 --- a/src/app/molecules/message/Message.jsx +++ b/src/app/molecules/message/Message.jsx @@ -5,7 +5,6 @@ import React, { 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'; @@ -322,7 +321,7 @@ function getMyEmojiEvent(emojiKey, eventId, roomTimeline) { 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(); @@ -330,17 +329,17 @@ function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) { 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) => ( @@ -354,24 +353,22 @@ function genReactionMsg(userIds, reaction) { ))} {' reacted with '} - {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 ( {users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}} + content={{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}} > + +
+ )} + { + usablePacks.length > 0 + ? usablePacks.reverse().map((mEvent) => ( + + )) : ( +
+ No emoji or sticker pack. +
+ ) + } + + ); +} + +RoomEmojis.propTypes = { + roomId: PropTypes.string.isRequired, +}; + +export default RoomEmojis; diff --git a/src/app/molecules/room-emojis/RoomEmojis.scss b/src/app/molecules/room-emojis/RoomEmojis.scss new file mode 100644 index 0000000..7ba2b49 --- /dev/null +++ b/src/app/molecules/room-emojis/RoomEmojis.scss @@ -0,0 +1,29 @@ +.room-emojis { + .image-pack, + .room-emojis__add-pack, + .room-emojis__empty { + margin: var(--sp-normal) 0; + background-color: var(--bg-surface); + border-radius: var(--bo-radius); + box-shadow: var(--bs-surface-border); + overflow: hidden; + + & > .context-menu__header:first-child { + margin-top: 2px; + } + } + &__add-pack { + & form { + margin: var(--sp-normal); + display: flex; + gap: var(--sp-normal); + & .input-container { + flex-grow: 1; + } + } + } + &__empty { + padding: var(--sp-extra-loose) var(--sp-normal); + text-align: center; + } +} \ 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 864a0bf..b97cab0 100644 --- a/src/app/organisms/emoji-board/EmojiBoard.jsx +++ b/src/app/organisms/emoji-board/EmojiBoard.jsx @@ -70,7 +70,7 @@ const EmojiGroup = React.memo(({ name, groupEmojis }) => { unicode={`:${emoji.shortcode}:`} shortcodes={emoji.shortcode} src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)} - data-mx-emoticon + data-mx-emoticon={emoji.mxc} /> ) } @@ -141,10 +141,13 @@ function EmojiBoard({ onSelect, searchRef }) { function getEmojiDataFromTarget(target) { 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) { @@ -202,21 +205,23 @@ function EmojiBoard({ onSelect, searchRef }) { 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; + 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); } - - setAvailableEmojis(packs); }; const onOpen = () => { @@ -260,7 +265,7 @@ function EmojiBoard({ onSelect, searchRef }) { { availableEmojis.map((pack) => ( { availableEmojis.map((pack) => { - const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc); + const src = initMatrix.matrixClient + .mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc); return ( 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/EmojiBoard.scss b/src/app/organisms/emoji-board/EmojiBoard.scss index 73f3ab3..6883e18 100644 --- a/src/app/organisms/emoji-board/EmojiBoard.scss +++ b/src/app/organisms/emoji-board/EmojiBoard.scss @@ -84,6 +84,7 @@ .emoji { width: 32px; height: 32px; + object-fit: contain; } } & > p:last-child { @@ -123,6 +124,7 @@ & .emoji { width: 38px; height: 38px; + object-fit: contain; padding: var(--emoji-padding); cursor: pointer; &:hover { diff --git a/src/app/organisms/emoji-board/custom-emoji.js b/src/app/organisms/emoji-board/custom-emoji.js index 4147c13..1298e6a 100644 --- a/src/app/organisms/emoji-board/custom-emoji.js +++ b/src/app/organisms/emoji-board/custom-emoji.js @@ -1,135 +1,224 @@ 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); + } - 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]; + 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; + } + + 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); }); @@ -138,7 +227,7 @@ function getShortcodeToEmoji(room) { } }); - getRelevantPacks(room).reverse() + getRelevantPacks(mx, rooms) .flatMap((pack) => pack.getEmojis()) .forEach((emoji) => { allEmoji.set(emoji.shortcode, emoji); @@ -150,7 +239,7 @@ function getShortcodeToEmoji(room) { 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); @@ -159,27 +248,20 @@ function getShortcodeToCustomEmoji(room) { 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, }; diff --git a/src/app/organisms/room/RoomSettings.jsx b/src/app/organisms/room/RoomSettings.jsx index 50c5e51..6327734 100644 --- a/src/app/organisms/room/RoomSettings.jsx +++ b/src/app/organisms/room/RoomSettings.jsx @@ -25,9 +25,11 @@ import RoomHistoryVisibility from '../../molecules/room-history-visibility/RoomH 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'; @@ -42,6 +44,7 @@ const tabText = { GENERAL: 'General', SEARCH: 'Search', MEMBERS: 'Members', + EMOJIS: 'Emojis', PERMISSIONS: 'Permissions', SECURITY: 'Security', }; @@ -58,6 +61,10 @@ const tabItems = [{ iconSrc: UserIC, text: tabText.MEMBERS, disabled: false, +}, { + iconSrc: EmojiIC, + text: tabText.EMOJIS, + disabled: false, }, { iconSrc: ShieldUserIC, text: tabText.PERMISSIONS, @@ -197,6 +204,7 @@ function RoomSettings({ roomId }) { {selectedTab.text === tabText.GENERAL && } {selectedTab.text === tabText.SEARCH && } {selectedTab.text === tabText.MEMBERS && } + {selectedTab.text === tabText.EMOJIS && } {selectedTab.text === tabText.PERMISSIONS && } {selectedTab.text === tabText.SECURITY && } @@ -210,7 +218,5 @@ RoomSettings.propTypes = { roomId: PropTypes.string.isRequired, }; -export { - RoomSettings as default, - tabText, -}; +export default RoomSettings; +export { tabText }; diff --git a/src/app/organisms/room/RoomViewCmdBar.jsx b/src/app/organisms/room/RoomViewCmdBar.jsx index 9c47024..68919aa 100644 --- a/src/app/organisms/room/RoomViewCmdBar.jsx +++ b/src/app/organisms/room/RoomViewCmdBar.jsx @@ -21,7 +21,7 @@ import AsyncSearch from '../../../util/AsyncSearch'; 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', @@ -213,9 +213,15 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) { 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) => ({ @@ -247,7 +253,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) { } if (myCmd.prefix === '@') { viewEvent.emit('cmd_fired', { - replace: myCmd.result.name, + replace: `@${myCmd.result.userId}`, }); } deactivateCmd(); diff --git a/src/app/organisms/room/RoomViewInput.jsx b/src/app/organisms/room/RoomViewInput.jsx index 704dd9a..4a7b2bf 100644 --- a/src/app/organisms/room/RoomViewInput.jsx +++ b/src/app/organisms/room/RoomViewInput.jsx @@ -8,7 +8,7 @@ import TextareaAutosize from 'react-autosize-textarea'; 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'; @@ -20,9 +20,12 @@ import IconButton from '../../atoms/button/IconButton'; 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'; @@ -128,7 +131,11 @@ function RoomViewInput({ } 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(); } @@ -199,6 +206,33 @@ function RoomViewInput({ 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 === ''; @@ -338,6 +372,29 @@ function RoomViewInput({ {isMarkdown && }
+ { + openReusableContextMenu( + 'top', + (() => { + const cords = getEventCords(e); + cords.y -= 20; + return cords; + })(), + (closeMenu) => ( + { + handleSendSticker(data); + closeMenu(); + }} + /> + ), + ); + }} + tooltip="Sticker" + src={StickerIC} + /> { const cords = getEventCords(e); diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx index b0f45f4..b50c992 100644 --- a/src/app/organisms/settings/Settings.jsx +++ b/src/app/organisms/settings/Settings.jsx @@ -24,6 +24,7 @@ import PopupWindow from '../../molecules/popup-window/PopupWindow'; 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'; @@ -31,6 +32,7 @@ import KeyBackup from './KeyBackup'; 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'; @@ -169,6 +171,15 @@ function NotificationsSection() { ); } +function EmojiSection() { + return ( + <> +
+
+ + ); +} + function SecuritySection() { return (
@@ -250,6 +261,7 @@ function AboutSection() { export const tabText = { APPEARANCE: 'Appearance', NOTIFICATIONS: 'Notifications', + EMOJI: 'Emoji', SECURITY: 'Security', ABOUT: 'About', }; @@ -263,6 +275,11 @@ const tabItems = [{ iconSrc: BellIC, disabled: false, render: () => , +}, { + text: tabText.EMOJI, + iconSrc: EmojiIC, + disabled: false, + render: () => , }, { text: tabText.SECURITY, iconSrc: LockIC, diff --git a/src/app/organisms/settings/Settings.scss b/src/app/organisms/settings/Settings.scss index dac53d7..d77e634 100644 --- a/src/app/organisms/settings/Settings.scss +++ b/src/app/organisms/settings/Settings.scss @@ -40,7 +40,8 @@ .settings-notifications, .settings-security__card, .settings-security .device-manage, -.settings-about__card { +.settings-about__card, +.settings-emoji__card { @extend .settings-window__card; } diff --git a/src/app/organisms/space-settings/SpaceSettings.jsx b/src/app/organisms/space-settings/SpaceSettings.jsx index 4373599..2c9d6d4 100644 --- a/src/app/organisms/space-settings/SpaceSettings.jsx +++ b/src/app/organisms/space-settings/SpaceSettings.jsx @@ -25,6 +25,7 @@ import RoomVisibility from '../../molecules/room-visibility/RoomVisibility'; 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'; @@ -35,6 +36,7 @@ import PinIC from '../../../../public/res/ic/outlined/pin.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'; @@ -42,6 +44,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate'; const tabText = { GENERAL: 'General', MEMBERS: 'Members', + EMOJIS: 'Emojis', PERMISSIONS: 'Permissions', }; @@ -53,6 +56,10 @@ const tabItems = [{ iconSrc: UserIC, text: tabText.MEMBERS, disabled: false, +}, { + iconSrc: EmojiIC, + text: tabText.EMOJIS, + disabled: false, }, { iconSrc: ShieldUserIC, text: tabText.PERMISSIONS, @@ -178,6 +185,7 @@ function SpaceSettings() {
{selectedTab.text === tabText.GENERAL && } {selectedTab.text === tabText.MEMBERS && } + {selectedTab.text === tabText.EMOJIS && } {selectedTab.text === tabText.PERMISSIONS && }
diff --git a/src/app/organisms/sticker-board/StickerBoard.jsx b/src/app/organisms/sticker-board/StickerBoard.jsx new file mode 100644 index 0000000..53b7563 --- /dev/null +++ b/src/app/organisms/sticker-board/StickerBoard.jsx @@ -0,0 +1,88 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import React from 'react'; +import PropTypes from 'prop-types'; +import './StickerBoard.scss'; + +import initMatrix from '../../../client/initMatrix'; +import { getRelevantPacks } from '../emoji-board/custom-emoji'; + +import Text from '../../atoms/text/Text'; +import ScrollView from '../../atoms/scroll/ScrollView'; + +function StickerBoard({ roomId, onSelect }) { + const mx = initMatrix.matrixClient; + const room = mx.getRoom(roomId); + + const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId); + const parentRooms = [...parentIds].map((id) => mx.getRoom(id)); + + const packs = getRelevantPacks( + mx, + [room, ...parentRooms], + ).filter((pack) => pack.getStickers().length !== 0); + + function isTargetNotSticker(target) { + return target.classList.contains('sticker-board__sticker') === false; + } + function getStickerData(target) { + const mxc = target.getAttribute('data-mx-sticker'); + const body = target.getAttribute('title'); + const httpUrl = target.getAttribute('src'); + return { mxc, body, httpUrl }; + } + const handleOnSelect = (e) => { + if (isTargetNotSticker(e.target)) return; + + const stickerData = getStickerData(e.target); + onSelect(stickerData); + }; + + const renderPack = (pack) => ( +
+ {pack.displayName ?? 'Unknown'} +
+ {pack.getStickers().map((sticker) => ( + {sticker.shortcode} + ))} +
+
+ ); + + return ( +
+
+ +
+ { + packs.length > 0 + ? packs.map(renderPack) + : ( +
+ There is no sticker pack. +
+ ) + } +
+
+
+
+
+ ); +} +StickerBoard.propTypes = { + roomId: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, +}; + +export default StickerBoard; diff --git a/src/app/organisms/sticker-board/StickerBoard.scss b/src/app/organisms/sticker-board/StickerBoard.scss new file mode 100644 index 0000000..be8ad35 --- /dev/null +++ b/src/app/organisms/sticker-board/StickerBoard.scss @@ -0,0 +1,60 @@ +@use '../../partials/dir'; + +.sticker-board { + --sticker-board-height: 390px; + --sticker-board-width: 286px; + display: flex; + height: var(--sticker-board-height); + + &__container { + flex-grow: 1; + min-width: 0; + width: var(--sticker-board-width); + display: flex; + } + + &__content { + min-height: 100%; + } + + &__pack { + margin-bottom: var(--sp-normal); + position: relative; + + &-header { + position: sticky; + top: 0; + z-index: 99; + background-color: var(--bg-surface); + + @include dir.side(margin, var(--sp-extra-tight), 0); + padding: var(--sp-extra-tight) var(--sp-ultra-tight); + text-transform: uppercase; + box-shadow: 0 -4px 0 0 var(--bg-surface); + border-bottom: 1px solid var(--bg-surface-border); + } + &-items { + margin: var(--sp-tight); + @include dir.side(margin, var(--sp-normal), var(--sp-extra-tight)); + display: flex; + flex-wrap: wrap; + gap: var(--sp-normal) var(--sp-tight); + + img { + width: 76px; + height: 76px; + object-fit: contain; + cursor: pointer; + } + } + } + + &__empty { + width: 100%; + height: var(--sticker-board-height); + display: flex; + justify-content: center; + align-items: center; + text-align: center; + } +} \ No newline at end of file diff --git a/src/client/action/roomTimeline.js b/src/client/action/roomTimeline.js index 8297bf0..41c62d4 100644 --- a/src/client/action/roomTimeline.js +++ b/src/client/action/roomTimeline.js @@ -11,17 +11,18 @@ async function redactEvent(roomId, eventId, reason) { } } -async function sendReaction(roomId, toEventId, reaction) { +async function sendReaction(roomId, toEventId, reaction, shortcode) { const mx = initMatrix.matrixClient; - + const 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); } diff --git a/src/client/initMatrix.js b/src/client/initMatrix.js index aec2f3d..2118be5 100644 --- a/src/client/initMatrix.js +++ b/src/client/initMatrix.js @@ -67,7 +67,7 @@ class InitMatrix extends EventEmitter { 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'); } diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 882c7bc..2377c8d 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'); @@ -120,14 +109,13 @@ function bindReplyToContent(roomId, reply, content) { 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); @@ -135,17 +123,25 @@ function formatAndEmojifyText(room, 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 tag + .forEach((mxidMatch) => { + const tag = `${userIdsToDisplayNames[mxidMatch[0]]}`; + + 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 = `