Custom emoji & Sticker support (#686)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sat, 6 Aug 2022 03:34:23 +0000 (09:04 +0530)
committerGitHub <noreply@github.com>
Sat, 6 Aug 2022 03:34:23 +0000 (09:04 +0530)
* 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

33 files changed:
public/res/ic/outlined/sticker.svg [new file with mode: 0644]
src/app/atoms/button/Button.scss
src/app/molecules/image-pack/ImagePack.jsx [new file with mode: 0644]
src/app/molecules/image-pack/ImagePack.scss [new file with mode: 0644]
src/app/molecules/image-pack/ImagePackItem.jsx [new file with mode: 0644]
src/app/molecules/image-pack/ImagePackItem.scss [new file with mode: 0644]
src/app/molecules/image-pack/ImagePackProfile.jsx [new file with mode: 0644]
src/app/molecules/image-pack/ImagePackProfile.scss [new file with mode: 0644]
src/app/molecules/image-pack/ImagePackUpload.jsx [new file with mode: 0644]
src/app/molecules/image-pack/ImagePackUpload.scss [new file with mode: 0644]
src/app/molecules/image-pack/ImagePackUsageSelector.jsx [new file with mode: 0644]
src/app/molecules/image-upload/ImageUpload.jsx
src/app/molecules/media/Media.jsx
src/app/molecules/media/Media.scss
src/app/molecules/message/Message.jsx
src/app/molecules/message/Message.scss
src/app/molecules/room-emojis/RoomEmojis.jsx [new file with mode: 0644]
src/app/molecules/room-emojis/RoomEmojis.scss [new file with mode: 0644]
src/app/organisms/emoji-board/EmojiBoard.jsx
src/app/organisms/emoji-board/EmojiBoard.scss
src/app/organisms/emoji-board/custom-emoji.js
src/app/organisms/room/RoomSettings.jsx
src/app/organisms/room/RoomViewCmdBar.jsx
src/app/organisms/room/RoomViewInput.jsx
src/app/organisms/settings/Settings.jsx
src/app/organisms/settings/Settings.scss
src/app/organisms/space-settings/SpaceSettings.jsx
src/app/organisms/sticker-board/StickerBoard.jsx [new file with mode: 0644]
src/app/organisms/sticker-board/StickerBoard.scss [new file with mode: 0644]
src/client/action/roomTimeline.js
src/client/initMatrix.js
src/client/state/RoomsInput.js
src/util/common.js

diff --git a/public/res/ic/outlined/sticker.svg b/public/res/ic/outlined/sticker.svg
new file mode 100644 (file)
index 0000000..bc486e5
--- /dev/null
@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16 3L21 8V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H16ZM19 9H17C15.8954 9 15 8.10457 15 7V5H5V19H19V9Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12H9Z" fill="black"/>
+</svg>
index 7b12195c21a52818049dae660c9574868108cd0c..e1a01bb05eeb765b05c6696b758f6c88a4d1fc5d 100644 (file)
   &--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 (file)
index 0000000..725291d
--- /dev/null
@@ -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(
+    <Text variant="s1" weight="medium">Rename</Text>,
+    (requestClose) => (
+      <div style={{ padding: 'var(--sp-normal)' }}>
+        <form
+          onSubmit={(e) => {
+            e.preventDefault();
+            const sc = e.target.shortcode.value;
+            if (sc.trim() === '') return;
+            isCompleted = true;
+            resolve(sc.trim());
+            requestClose();
+          }}
+        >
+          <Input
+            value={shortcode}
+            name="shortcode"
+            label="Shortcode"
+            autoFocus
+            required
+          />
+          <div style={{ height: 'var(--sp-normal)' }} />
+          <Button variant="primary" type="submit">Rename</Button>
+        </form>
+      </div>
+    ),
+    () => {
+      if (!isCompleted) resolve(null);
+    },
+  );
+});
+
+function getUsage(usage) {
+  if (usage.includes('emoticon') && usage.includes('sticker')) return 'both';
+  if (usage.includes('emoticon')) return 'emoticon';
+  if (usage.includes('sticker')) return 'sticker';
+
+  return 'both';
+}
+
+function isGlobalPack(roomId, stateKey) {
+  const mx = initMatrix.matrixClient;
+  const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
+  if (typeof globalContent !== 'object') return false;
+
+  const { rooms } = globalContent;
+  if (typeof rooms !== 'object') return false;
+
+  return rooms[roomId]?.[stateKey] !== undefined;
+}
+
+function useRoomImagePack(roomId, stateKey) {
+  const mx = initMatrix.matrixClient;
+  const room = mx.getRoom(roomId);
+
+  const packEvent = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
+  const pack = useMemo(() => (
+    ImagePackBuilder.parsePack(packEvent.getId(), packEvent.getContent())
+  ), [room, stateKey]);
+
+  const sendPackContent = (content) => {
+    mx.sendStateEvent(roomId, 'im.ponies.room_emotes', content, stateKey);
+  };
+
+  return {
+    pack,
+    sendPackContent,
+  };
+}
+
+function useUserImagePack() {
+  const mx = initMatrix.matrixClient;
+  const packEvent = mx.getAccountData('im.ponies.user_emotes');
+  const pack = useMemo(() => (
+    ImagePackBuilder.parsePack(mx.getUserId(), packEvent?.getContent() ?? {
+      pack: { display_name: 'Personal' },
+      images: {},
+    })
+  ), []);
+
+  const sendPackContent = (content) => {
+    mx.setAccountData('im.ponies.user_emotes', content);
+  };
+
+  return {
+    pack,
+    sendPackContent,
+  };
+}
+
+function useImagePackHandles(pack, sendPackContent) {
+  const [, forceUpdate] = useReducer((count) => count + 1, 0);
+
+  const getNewKey = (key) => {
+    if (typeof key !== 'string') return undefined;
+    let newKey = key?.replace(/\s/g, '-');
+    if (pack.getImages().get(newKey)) {
+      newKey = suffixRename(
+        newKey,
+        (suffixedKey) => pack.getImages().get(suffixedKey),
+      );
+    }
+    return newKey;
+  };
+
+  const handleAvatarChange = (url) => {
+    pack.setAvatarUrl(url);
+    sendPackContent(pack.getContent());
+    forceUpdate();
+  };
+  const handleEditProfile = (name, attribution) => {
+    pack.setDisplayName(name);
+    pack.setAttribution(attribution);
+    sendPackContent(pack.getContent());
+    forceUpdate();
+  };
+  const handleUsageChange = (newUsage) => {
+    const usage = [];
+    if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
+    if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
+    pack.setUsage(usage);
+    pack.getImages().forEach((img) => pack.setImageUsage(img.shortcode, undefined));
+
+    sendPackContent(pack.getContent());
+    forceUpdate();
+  };
+
+  const handleRenameItem = async (key) => {
+    const newKey = getNewKey(await renameImagePackItem(key));
+
+    if (!newKey || newKey === key) return;
+    pack.updateImageKey(key, newKey);
+
+    sendPackContent(pack.getContent());
+    forceUpdate();
+  };
+  const handleDeleteItem = async (key) => {
+    const isConfirmed = await confirmDialog(
+      'Delete',
+      `Are you sure that you want to delete "${key}"?`,
+      'Delete',
+      'danger',
+    );
+    if (!isConfirmed) return;
+    pack.removeImage(key);
+
+    sendPackContent(pack.getContent());
+    forceUpdate();
+  };
+  const handleUsageItem = (key, newUsage) => {
+    const usage = [];
+    if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
+    if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
+    pack.setImageUsage(key, usage);
+
+    sendPackContent(pack.getContent());
+    forceUpdate();
+  };
+  const handleAddItem = (key, url) => {
+    const newKey = getNewKey(key);
+    if (!newKey || !url) return;
+
+    pack.addImage(newKey, {
+      url,
+    });
+
+    sendPackContent(pack.getContent());
+    forceUpdate();
+  };
+
+  return {
+    handleAvatarChange,
+    handleEditProfile,
+    handleUsageChange,
+    handleRenameItem,
+    handleDeleteItem,
+    handleUsageItem,
+    handleAddItem,
+  };
+}
+
+function addGlobalImagePack(mx, roomId, stateKey) {
+  const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
+  if (!content.rooms) content.rooms = {};
+  if (!content.rooms[roomId]) content.rooms[roomId] = {};
+  content.rooms[roomId][stateKey] = {};
+  return mx.setAccountData('im.ponies.emote_rooms', content);
+}
+function removeGlobalImagePack(mx, roomId, stateKey) {
+  const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
+  if (!content.rooms) return Promise.resolve();
+  if (!content.rooms[roomId]) return Promise.resolve();
+  delete content.rooms[roomId][stateKey];
+  if (Object.keys(content.rooms[roomId]).length === 0) {
+    delete content.rooms[roomId];
+  }
+  return mx.setAccountData('im.ponies.emote_rooms', content);
+}
+
+function ImagePack({ roomId, stateKey, handlePackDelete }) {
+  const mx = initMatrix.matrixClient;
+  const room = mx.getRoom(roomId);
+  const [viewMore, setViewMore] = useState(false);
+  const [isGlobal, setIsGlobal] = useState(isGlobalPack(roomId, stateKey));
+
+  const { pack, sendPackContent } = useRoomImagePack(roomId, stateKey);
+
+  const {
+    handleAvatarChange,
+    handleEditProfile,
+    handleUsageChange,
+    handleRenameItem,
+    handleDeleteItem,
+    handleUsageItem,
+    handleAddItem,
+  } = useImagePackHandles(pack, sendPackContent);
+
+  const handleGlobalChange = (isG) => {
+    setIsGlobal(isG);
+    if (isG) addGlobalImagePack(mx, roomId, stateKey);
+    else removeGlobalImagePack(mx, roomId, stateKey);
+  };
+
+  const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
+  const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
+
+  const handleDeletePack = async () => {
+    const isConfirmed = await confirmDialog(
+      'Delete Pack',
+      `Are you sure that you want to delete "${pack.displayName}"?`,
+      'Delete',
+      'danger',
+    );
+    if (!isConfirmed) return;
+    handlePackDelete(stateKey);
+  };
+
+  const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
+
+  return (
+    <div className="image-pack">
+      <ImagePackProfile
+        avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
+        displayName={pack.displayName ?? 'Unknown'}
+        attribution={pack.attribution}
+        usage={getUsage(pack.usage)}
+        onUsageChange={canChange ? handleUsageChange : null}
+        onAvatarChange={canChange ? handleAvatarChange : null}
+        onEditProfile={canChange ? handleEditProfile : null}
+      />
+      { canChange && (
+        <ImagePackUpload onUpload={handleAddItem} />
+      )}
+      { images.length === 0 ? null : (
+        <div>
+          <div className="image-pack__header">
+            <Text variant="b3">Image</Text>
+            <Text variant="b3">Shortcode</Text>
+            <Text variant="b3">Usage</Text>
+          </div>
+          {images.map(([shortcode, image]) => (
+            <ImagePackItem
+              key={shortcode}
+              url={mx.mxcUrlToHttp(image.mxc)}
+              shortcode={shortcode}
+              usage={getUsage(image.usage)}
+              onUsageChange={canChange ? handleUsageItem : undefined}
+              onDelete={canChange ? handleDeleteItem : undefined}
+              onRename={canChange ? handleRenameItem : undefined}
+            />
+          ))}
+        </div>
+      )}
+      {(pack.images.size > 2 || handlePackDelete) && (
+        <div className="image-pack__footer">
+          {pack.images.size > 2 && (
+            <Button onClick={() => setViewMore(!viewMore)}>
+              {
+                viewMore
+                  ? 'View less'
+                  : `View ${pack.images.size - 2} more`
+              }
+            </Button>
+          )}
+          { handlePackDelete && <Button variant="danger" onClick={handleDeletePack}>Delete Pack</Button>}
+        </div>
+      )}
+      <div className="image-pack__global">
+        <Checkbox variant="positive" onToggle={handleGlobalChange} isActive={isGlobal} />
+        <div>
+          <Text variant="b2">Use globally</Text>
+          <Text variant="b3">Add this pack to your account to use in all rooms.</Text>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+ImagePack.defaultProps = {
+  handlePackDelete: null,
+};
+ImagePack.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  stateKey: PropTypes.string.isRequired,
+  handlePackDelete: PropTypes.func,
+};
+
+function ImagePackUser() {
+  const mx = initMatrix.matrixClient;
+  const [viewMore, setViewMore] = useState(false);
+
+  const { pack, sendPackContent } = useUserImagePack();
+
+  const {
+    handleAvatarChange,
+    handleEditProfile,
+    handleUsageChange,
+    handleRenameItem,
+    handleDeleteItem,
+    handleUsageItem,
+    handleAddItem,
+  } = useImagePackHandles(pack, sendPackContent);
+
+  const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
+
+  return (
+    <div className="image-pack">
+      <ImagePackProfile
+        avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
+        displayName={pack.displayName ?? 'Personal'}
+        attribution={pack.attribution}
+        usage={getUsage(pack.usage)}
+        onUsageChange={handleUsageChange}
+        onAvatarChange={handleAvatarChange}
+        onEditProfile={handleEditProfile}
+      />
+      <ImagePackUpload onUpload={handleAddItem} />
+      { images.length === 0 ? null : (
+        <div>
+          <div className="image-pack__header">
+            <Text variant="b3">Image</Text>
+            <Text variant="b3">Shortcode</Text>
+            <Text variant="b3">Usage</Text>
+          </div>
+          {images.map(([shortcode, image]) => (
+            <ImagePackItem
+              key={shortcode}
+              url={mx.mxcUrlToHttp(image.mxc)}
+              shortcode={shortcode}
+              usage={getUsage(image.usage)}
+              onUsageChange={handleUsageItem}
+              onDelete={handleDeleteItem}
+              onRename={handleRenameItem}
+            />
+          ))}
+        </div>
+      )}
+      {(pack.images.size > 2) && (
+        <div className="image-pack__footer">
+          <Button onClick={() => setViewMore(!viewMore)}>
+            {
+              viewMore
+                ? 'View less'
+                : `View ${pack.images.size - 2} more`
+            }
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+}
+
+function useGlobalImagePack() {
+  const [, forceUpdate] = useReducer((count) => count + 1, 0);
+  const mx = initMatrix.matrixClient;
+
+  const roomIdToStateKeys = new Map();
+  const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? { rooms: {} };
+  const { rooms } = globalContent;
+
+  Object.keys(rooms).forEach((roomId) => {
+    if (typeof rooms[roomId] !== 'object') return;
+    const room = mx.getRoom(roomId);
+    const stateKeys = Object.keys(rooms[roomId]);
+    if (!room || stateKeys.length === 0) return;
+    roomIdToStateKeys.set(roomId, stateKeys);
+  });
+
+  useEffect(() => {
+    const handleEvent = (event) => {
+      if (event.getType() === 'im.ponies.emote_rooms') forceUpdate();
+    };
+    mx.addListener('accountData', handleEvent);
+    return () => {
+      mx.removeListener('accountData', handleEvent);
+    };
+  }, []);
+
+  return roomIdToStateKeys;
+}
+
+function ImagePackGlobal() {
+  const mx = initMatrix.matrixClient;
+  const roomIdToStateKeys = useGlobalImagePack();
+
+  const handleChange = (roomId, stateKey) => {
+    removeGlobalImagePack(mx, roomId, stateKey);
+  };
+
+  return (
+    <div className="image-pack-global">
+      <MenuHeader>Global packs</MenuHeader>
+      <div>
+        {
+          roomIdToStateKeys.size > 0
+            ? [...roomIdToStateKeys].map(([roomId, stateKeys]) => {
+              const room = mx.getRoom(roomId);
+              return (
+                stateKeys.map((stateKey) => {
+                  const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
+                  const pack = ImagePackBuilder.parsePack(data?.getId(), data?.getContent());
+                  if (!pack) return null;
+                  return (
+                    <div className="image-pack__global" key={pack.id}>
+                      <Checkbox variant="positive" onToggle={() => handleChange(roomId, stateKey)} isActive />
+                      <div>
+                        <Text variant="b2">{pack.displayName ?? 'Unknown'}</Text>
+                        <Text variant="b3">{room.name}</Text>
+                      </div>
+                    </div>
+                  );
+                })
+              );
+            })
+            : <div className="image-pack-global__empty"><Text>No global packs</Text></div>
+        }
+      </div>
+    </div>
+  );
+}
+
+export default ImagePack;
+
+export { ImagePackUser, ImagePackGlobal };
diff --git a/src/app/molecules/image-pack/ImagePack.scss b/src/app/molecules/image-pack/ImagePack.scss
new file mode 100644 (file)
index 0000000..91d6a18
--- /dev/null
@@ -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 (file)
index 0000000..2743679
--- /dev/null
@@ -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) => (
+        <ImagePackUsageSelector
+          usage={usage}
+          onSelect={(newUsage) => {
+            onUsageChange(shortcode, newUsage);
+            closeMenu();
+          }}
+        />
+      ),
+    );
+  };
+
+  return (
+    <div className="image-pack-item">
+      <Avatar imageSrc={url} size="extra-small" text={shortcode} bgColor="black" />
+      <div className="image-pack-item__content">
+        <Text>{shortcode}</Text>
+      </div>
+      <div className="image-pack-item__usage">
+        <div className="image-pack-item__btn">
+          {onRename && <IconButton tooltip="Rename" size="extra-small" src={PencilIC} onClick={() => onRename(shortcode)} />}
+          {onDelete && <IconButton tooltip="Delete" size="extra-small" src={BinIC} onClick={() => onDelete(shortcode)} />}
+        </div>
+        <Button onClick={onUsageChange ? handleUsageSelect : undefined}>
+          {onUsageChange && <RawIcon src={ChevronBottomIC} size="extra-small" />}
+          <Text variant="b2">
+            {usage === 'emoticon' && 'Emoji'}
+            {usage === 'sticker' && 'Sticker'}
+            {usage === 'both' && 'Both'}
+          </Text>
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+ImagePackItem.defaultProps = {
+  onUsageChange: null,
+  onDelete: null,
+  onRename: null,
+};
+ImagePackItem.propTypes = {
+  url: PropTypes.string.isRequired,
+  shortcode: PropTypes.string.isRequired,
+  usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
+  onUsageChange: PropTypes.func,
+  onDelete: PropTypes.func,
+  onRename: PropTypes.func,
+};
+
+export default ImagePackItem;
diff --git a/src/app/molecules/image-pack/ImagePackItem.scss b/src/app/molecules/image-pack/ImagePackItem.scss
new file mode 100644 (file)
index 0000000..ab1be3a
--- /dev/null
@@ -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 (file)
index 0000000..b639936
--- /dev/null
@@ -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) => (
+        <ImagePackUsageSelector
+          usage={usage}
+          onSelect={(newUsage) => {
+            onUsageChange(newUsage);
+            closeMenu();
+          }}
+        />
+      ),
+    );
+  };
+
+  return (
+    <div className="image-pack-profile">
+      {
+        onAvatarChange
+          ? (
+            <ImageUpload
+              bgColor="#555"
+              text={displayName}
+              imageSrc={avatarUrl}
+              size="normal"
+              onUpload={onAvatarChange}
+              onRequestRemove={() => onAvatarChange(undefined)}
+            />
+          )
+          : <Avatar bgColor="#555" text={displayName} imageSrc={avatarUrl} size="normal" />
+      }
+      <div className="image-pack-profile__content">
+        {
+          isEdit
+            ? (
+              <form onSubmit={handleSubmit}>
+                <Input name="nameInput" label="Name" value={displayName} required />
+                <Input name="attributionInput" label="Attribution" value={attribution} resizable />
+                <div>
+                  <Button variant="primary" type="submit">Save</Button>
+                  <Button onClick={() => setIsEdit(false)}>Cancel</Button>
+                </div>
+              </form>
+            ) : (
+              <>
+                <div>
+                  <Text>{displayName}</Text>
+                  {onEditProfile && <IconButton size="extra-small" onClick={() => setIsEdit(true)} src={PencilIC} tooltip="Edit" />}
+                </div>
+                {attribution && <Text variant="b3">{attribution}</Text>}
+              </>
+            )
+        }
+      </div>
+      <div className="image-pack-profile__usage">
+        <Text variant="b3">Pack usage</Text>
+        <Button
+          onClick={onUsageChange ? handleUsageSelect : undefined}
+          iconSrc={onUsageChange ? ChevronBottomIC : null}
+        >
+          <Text>
+            {usage === 'emoticon' && 'Emoji'}
+            {usage === 'sticker' && 'Sticker'}
+            {usage === 'both' && 'Both'}
+          </Text>
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+ImagePackProfile.defaultProps = {
+  avatarUrl: null,
+  attribution: null,
+  onUsageChange: null,
+  onAvatarChange: null,
+  onEditProfile: null,
+};
+ImagePackProfile.propTypes = {
+  avatarUrl: PropTypes.string,
+  displayName: PropTypes.string.isRequired,
+  attribution: PropTypes.string,
+  usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
+  onUsageChange: PropTypes.func,
+  onAvatarChange: PropTypes.func,
+  onEditProfile: PropTypes.func,
+};
+
+export default ImagePackProfile;
diff --git a/src/app/molecules/image-pack/ImagePackProfile.scss b/src/app/molecules/image-pack/ImagePackProfile.scss
new file mode 100644 (file)
index 0000000..d21212f
--- /dev/null
@@ -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 (file)
index 0000000..9358856
--- /dev/null
@@ -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 (
+    <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;
diff --git a/src/app/molecules/image-pack/ImagePackUpload.scss b/src/app/molecules/image-pack/ImagePackUpload.scss
new file mode 100644 (file)
index 0000000..75b57ed
--- /dev/null
@@ -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 (file)
index 0000000..279b381
--- /dev/null
@@ -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 (
+    <div>
+      <MenuHeader>Usage</MenuHeader>
+      <MenuItem
+        iconSrc={usage === 'emoticon' ? CheckIC : undefined}
+        variant={usage === 'emoticon' ? 'positive' : 'surface'}
+        onClick={() => onSelect('emoticon')}
+      >
+        Emoji
+      </MenuItem>
+      <MenuItem
+        iconSrc={usage === 'sticker' ? CheckIC : undefined}
+        variant={usage === 'sticker' ? 'positive' : 'surface'}
+        onClick={() => onSelect('sticker')}
+      >
+        Sticker
+      </MenuItem>
+      <MenuItem
+        iconSrc={usage === 'both' ? CheckIC : undefined}
+        variant={usage === 'both' ? 'positive' : 'surface'}
+        onClick={() => onSelect('both')}
+      >
+        Both
+      </MenuItem>
+    </div>
+  );
+}
+
+ImagePackUsageSelector.propTypes = {
+  usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
+  onSelect: PropTypes.func.isRequired,
+};
+
+export default ImagePackUsageSelector;
index 69564aa56bcdc0860a647b577fb4c4852044f94d..137d23bfff9fd452f255634a55d6bf9b17e5910c 100644 (file)
@@ -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}
         />
         <div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
-          {uploadPromise === null && <Text variant="b3" weight="bold">Upload</Text>}
+          {uploadPromise === null && (
+            size === 'large'
+              ? <Text variant="b3" weight="bold">Upload</Text>
+              : <RawIcon src={PlusIC} color="white" />
+          )}
           {uploadPromise !== null && <Spinner size="small" />}
         </div>
       </button>
@@ -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;
index 341dcb037e659635970ddd0e082d6e70497ec3ac..c4b4a1712a2a8565a05e57f52e440c3f0385f54d 100644 (file)
@@ -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 (
+    <div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
+      { url !== null && <img src={url || link} title={name} alt={name} />}
+    </div>
+  );
+}
+Sticker.defaultProps = {
+  file: null,
+  type: '',
+};
+Sticker.propTypes = {
+  name: PropTypes.string.isRequired,
+  width: null,
+  height: null,
+  width: PropTypes.number,
+  height: PropTypes.number,
+  link: PropTypes.string.isRequired,
+  file: PropTypes.shape({}),
+  type: PropTypes.string,
+};
+
 function Audio({
   name, link, type, file,
 }) {
@@ -315,5 +353,5 @@ Video.propTypes = {
 };
 
 export {
-  File, Image, Audio, Video,
+  File, Image, Sticker, Audio, Video,
 };
index 7b9d6f7c861f888df06ac6bdeda2d32c1bbac7a3..16cf8f7e2da046e27b07bff2eec2bf9bda494292 100644 (file)
   background-size: cover;
 }
 
+.sticker-container {
+  display: inline-flex;
+  max-width: 128px;
+  width: 100%;
+  & img {
+    width: 100% !important;
+  }
+}
+
 .image-container {
   & img {
     max-width: unset !important;
index c1370ec3d673fdc8a82074d268ad7264c1c46ca4..49337bdc1b29a5209a5a56e38ef533cb611b7f33 100644 (file)
@@ -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) {
         </React.Fragment>
       ))}
       <span style={{ opacity: '.6' }}>{' reacted with '}</span>
-      {twemojify(reaction, { className: 'react-emoji' })}
+      {twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
     </>
   );
 }
 
 function MessageReaction({
-  shortcodeToEmoji, reaction, count, users, isActive, onClick,
+  reaction, shortcode, count, users, isActive, onClick,
 }) {
-  const customEmojiMatch = reaction.match(/^:(\S+):$/);
   let customEmojiUrl = null;
-  if (customEmojiMatch) {
-    const customEmoji = shortcodeToEmoji.get(customEmojiMatch[1]);
-    customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(customEmoji?.mxc);
+  if (reaction.match(/^mxc:\/\/\S+$/)) {
+    customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
   }
   return (
     <Tooltip
       className="msg__reaction-tooltip"
-      content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}</Text>}
+      content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}</Text>}
     >
       <button
         onClick={onClick}
@@ -380,7 +377,7 @@ function MessageReaction({
       >
         {
           customEmojiUrl
-            ? <img className="react-emoji" draggable="false" alt={reaction} src={customEmojiUrl} />
+            ? <img className="react-emoji" draggable="false" alt={shortcode ?? reaction} src={customEmojiUrl} />
             : twemojify(reaction, { className: 'react-emoji' })
         }
         <Text variant="b3" className="msg__reaction-count">{count}</Text>
@@ -388,9 +385,12 @@ function MessageReaction({
     </Tooltip>
   );
 }
+MessageReaction.defaultProps = {
+  shortcode: undefined,
+};
 MessageReaction.propTypes = {
-  shortcodeToEmoji: PropTypes.shape({}).isRequired,
   reaction: PropTypes.node.isRequired,
+  shortcode: PropTypes.string,
   count: PropTypes.number.isRequired,
   users: PropTypes.arrayOf(PropTypes.string).isRequired,
   isActive: PropTypes.bool.isRequired,
@@ -401,11 +401,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
   const { roomId, room, reactionTimeline } = roomTimeline;
   const mx = initMatrix.matrixClient;
   const reactions = {};
-  const shortcodeToEmoji = getShortcodeToCustomEmoji(room);
   const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
 
   const eventReactions = reactionTimeline.get(mEvent.getId());
-  const addReaction = (key, count, senderId, isActive) => {
+  const addReaction = (key, shortcode, count, senderId, isActive) => {
     let reaction = reactions[key];
     if (reaction === undefined) {
       reaction = {
@@ -414,6 +413,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
         isActive: false,
       };
     }
+    if (shortcode) reaction.shortcode = shortcode;
     if (count) {
       reaction.count = count;
     } else {
@@ -429,9 +429,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
       if (rEvent.getRelation() === null) return;
       const reaction = rEvent.getRelation();
       const senderId = rEvent.getSender();
+      const { shortcode } = rEvent.getContent();
       const isActive = senderId === mx.getUserId();
 
-      addReaction(reaction.key, undefined, senderId, isActive);
+      addReaction(reaction.key, shortcode, undefined, senderId, isActive);
     });
   } else {
     // Use aggregated reactions
@@ -439,7 +440,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
     if (!aggregatedReaction) return null;
     aggregatedReaction.forEach((reaction) => {
       if (reaction.type !== 'm.reaction') return;
-      addReaction(reaction.key, reaction.count, undefined, false);
+      addReaction(reaction.key, undefined, reaction.count, undefined, false);
     });
   }
 
@@ -449,13 +450,13 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
         Object.keys(reactions).map((key) => (
           <MessageReaction
             key={key}
-            shortcodeToEmoji={shortcodeToEmoji}
             reaction={key}
+            shortcode={reactions[key].shortcode}
             count={reactions[key].count}
             users={reactions[key].users}
             isActive={reactions[key].isActive}
             onClick={() => {
-              toggleEmoji(roomId, mEvent.getId(), key, roomTimeline);
+              toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline);
             }}
           />
         ))
@@ -607,7 +608,7 @@ function genMediaContent(mE) {
   if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
 
   let msgType = mE.getContent()?.msgtype;
-  if (mE.getType() === 'm.sticker') msgType = 'm.image';
+  if (mE.getType() === 'm.sticker') msgType = 'm.sticker';
 
   switch (msgType) {
     case 'm.file':
@@ -630,6 +631,17 @@ function genMediaContent(mE) {
           type={mContent.info?.mimetype}
         />
       );
+    case 'm.sticker':
+      return (
+        <Media.Sticker
+          name={mContent.body}
+          width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
+          height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
+          link={mx.mxcUrlToHttp(mediaMXC)}
+          file={isEncryptedFile ? mContent.file : null}
+          type={mContent.info?.mimetype}
+        />
+      );
     case 'm.audio':
       return (
         <Media.Audio
index a1f505149b46700ebc08d939e4cc4af4ea75a760..155126412e26f5d1b36724217462e0a0e3eb0388 100644 (file)
   cursor: pointer;
 
   & .react-emoji {
-    width: 16px;
     height: 16px;
     margin: 2px;
   }
diff --git a/src/app/molecules/room-emojis/RoomEmojis.jsx b/src/app/molecules/room-emojis/RoomEmojis.jsx
new file mode 100644 (file)
index 0000000..81cee0a
--- /dev/null
@@ -0,0 +1,130 @@
+import React, { useReducer, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomEmojis.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import { suffixRename } from '../../../util/common';
+
+import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
+import Text from '../../atoms/text/Text';
+import Input from '../../atoms/input/Input';
+import Button from '../../atoms/button/Button';
+import ImagePack from '../image-pack/ImagePack';
+
+function useRoomPacks(room) {
+  const mx = initMatrix.matrixClient;
+  const [, forceUpdate] = useReducer((count) => count + 1, 0);
+
+  const packEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
+  const unUsablePacks = [];
+  const usablePacks = packEvents.filter((mEvent) => {
+    if (typeof mEvent.getContent()?.images !== 'object') {
+      unUsablePacks.push(mEvent);
+      return false;
+    }
+    return true;
+  });
+
+  useEffect(() => {
+    const handleEvent = (event, state, prevEvent) => {
+      if (event.getRoomId() !== room.roomId) return;
+      if (event.getType() !== 'im.ponies.room_emotes') return;
+      if (!prevEvent?.getContent()?.images || !event.getContent().images) {
+        forceUpdate();
+      }
+    };
+
+    mx.on('RoomState.events', handleEvent);
+    return () => {
+      mx.removeListener('RoomState.events', handleEvent);
+    };
+  }, [room, mx]);
+
+  const isStateKeyAvailable = (key) => !room.currentState.getStateEvents('im.ponies.room_emotes', key);
+
+  const createPack = async (name) => {
+    const packContent = {
+      pack: { display_name: name },
+      images: {},
+    };
+    let stateKey = '';
+    if (unUsablePacks.length > 0) {
+      const mEvent = unUsablePacks[0];
+      stateKey = mEvent.getStateKey();
+    } else {
+      stateKey = packContent.pack.display_name.replace(/\s/g, '-');
+      if (!isStateKeyAvailable(stateKey)) {
+        stateKey = suffixRename(
+          stateKey,
+          isStateKeyAvailable,
+        );
+      }
+    }
+    await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', packContent, stateKey);
+  };
+
+  const deletePack = async (stateKey) => {
+    await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', {}, stateKey);
+  };
+
+  return {
+    usablePacks,
+    createPack,
+    deletePack,
+  };
+}
+
+function RoomEmojis({ roomId }) {
+  const mx = initMatrix.matrixClient;
+  const room = mx.getRoom(roomId);
+
+  const { usablePacks, createPack, deletePack } = useRoomPacks(room);
+
+  const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
+  const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
+
+  const handlePackCreate = (e) => {
+    e.preventDefault();
+    const { nameInput } = e.target;
+    const name = nameInput.value.trim();
+    if (name === '') return;
+    nameInput.value = '';
+
+    createPack(name);
+  };
+
+  return (
+    <div className="room-emojis">
+      { canChange && (
+        <div className="room-emojis__add-pack">
+          <MenuHeader>Create Pack</MenuHeader>
+          <form onSubmit={handlePackCreate}>
+            <Input name="nameInput" placeholder="Pack Name" required />
+            <Button variant="primary" type="submit">Create pack</Button>
+          </form>
+        </div>
+      )}
+      {
+        usablePacks.length > 0
+          ? usablePacks.reverse().map((mEvent) => (
+            <ImagePack
+              key={mEvent.getId()}
+              roomId={roomId}
+              stateKey={mEvent.getStateKey()}
+              handlePackDelete={canChange ? deletePack : undefined}
+            />
+          )) : (
+            <div className="room-emojis__empty">
+              <Text>No emoji or sticker pack.</Text>
+            </div>
+          )
+      }
+    </div>
+  );
+}
+
+RoomEmojis.propTypes = {
+  roomId: PropTypes.string.isRequired,
+};
+
+export default RoomEmojis;
diff --git a/src/app/molecules/room-emojis/RoomEmojis.scss b/src/app/molecules/room-emojis/RoomEmojis.scss
new file mode 100644 (file)
index 0000000..7ba2b49
--- /dev/null
@@ -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
index 864a0bf60140f7a343d9e1bf28dd151963e046ec..b97cab0aeff3f013c80cb548753977ca6b77372c 100644 (file)
@@ -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;
-      }
 
-      setAvailableEmojis(packs);
+      const mx = initMatrix.matrixClient;
+      const room = mx.getRoom(selectedRoomId);
+      const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
+      const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
+      if (room) {
+        const packs = getRelevantPacks(
+          room.client,
+          [room, ...parentRooms],
+        ).filter((pack) => pack.getEmojis().length !== 0);
+
+        // Set an index for each pack so that we know where to jump when the user uses the nav
+        for (let i = 0; i < packs.length; i += 1) {
+          packs[i].packIndex = i;
+        }
+        setAvailableEmojis(packs);
+      }
     };
 
     const onOpen = () => {
@@ -260,7 +265,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"
@@ -293,13 +298,14 @@ function EmojiBoard({ onSelect, searchRef }) {
           <div className="emoji-board__nav-custom">
             {
               availableEmojis.map((pack) => {
-                const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc);
+                const src = initMatrix.matrixClient
+                  .mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc);
                 return (
                   <IconButton
                     onClick={() => openGroup(recentOffset + pack.packIndex)}
                     src={src}
                     key={pack.packIndex}
-                    tooltip={pack.displayName}
+                    tooltip={pack.displayName ?? 'Unknown'}
                     tooltipPlacement="right"
                     isImage
                   />
index 73f3ab3be9210fba22d68be484aca2316f300800..6883e18ee33b58eb1b0b414b1f19cf785ad4902b 100644 (file)
@@ -84,6 +84,7 @@
     .emoji {
       width: 32px;
       height: 32px;
+      object-fit: contain;
     }
   }
   & > p:last-child {
   & .emoji {
     width: 38px;
     height: 38px;
+    object-fit: contain;
     padding: var(--emoji-padding);
     cursor: pointer;
     &:hover {
index 4147c1308958640bffe7562b55f24aa3cb1ac910..1298e6ae6d739565da5c92462dfecb16dafebe8b 100644 (file)
 import { emojis } from './emoji';
 
-// Custom emoji are stored in one of three places:
-// - User emojis, which are stored in account data
-// - Room emojis, which are stored in state events in a room
-// - Emoji packs, which are rooms of emojis referenced in the account data or in a room's
-//   cannonical space
-//
-// Emojis and packs referenced from within a user's account data should be available
-// globally, while emojis and packs in rooms and spaces should only be available within
-// those spaces and rooms
+// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
 
 class ImagePack {
-  // Convert a raw image pack into a more maliable format
-  //
-  // Takes an image pack as per MSC 2545 (e.g. as in the Matrix spec), and converts it to a
-  // format used here, while filling in defaults.
-  //
-  // The room argument is the room the pack exists in, which is used as a fallback for
-  // missing properties
-  //
-  // Returns `null` if the rawPack is not a properly formatted image pack, although there
-  // is still a fair amount of tolerance for malformed packs.
-  static parsePack(rawPack, room) {
-    if (typeof rawPack.images === 'undefined') {
+  static parsePack(eventId, packContent) {
+    if (!eventId || typeof packContent?.images !== 'object') {
       return null;
     }
 
-    const pack = rawPack.pack ?? {};
+    return new ImagePack(eventId, packContent);
+  }
+
+  constructor(eventId, content) {
+    this.id = eventId;
+    this.content = JSON.parse(JSON.stringify(content));
+
+    this.applyPack(content);
+    this.applyImages(content);
+  }
+
+  applyPack(content) {
+    const pack = content.pack ?? {};
+
+    this.displayName = pack.display_name;
+    this.avatarUrl = pack.avatar_url;
+    this.usage = pack.usage ?? ['emoticon', 'sticker'];
+    this.attribution = pack.attribution;
+  }
 
-    const displayName = pack.display_name ?? (room ? room.name : undefined);
-    const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined);
-    const usage = pack.usage ?? ['emoticon', 'sticker'];
-    const { attribution } = pack;
-    const images = Object.entries(rawPack.images).flatMap((e) => {
-      const data = e[1];
-      const shortcode = e[0];
+  applyImages(content) {
+    this.images = new Map();
+    this.emoticons = [];
+    this.stickers = [];
+
+    Object.entries(content.images).forEach(([shortcode, data]) => {
       const mxc = data.url;
       const body = data.body ?? shortcode;
+      const usage = data.usage ?? this.usage;
       const { info } = data;
-      const usage_ = data.usage ?? usage;
 
-      if (mxc) {
-        return [{
-          shortcode, mxc, body, info, usage: usage_,
-        }];
+      if (!mxc) return;
+      const image = {
+        shortcode, mxc, body, usage, info,
+      };
+
+      this.images.set(shortcode, image);
+      if (usage.includes('emoticon')) {
+        this.emoticons.push(image);
+      }
+      if (usage.includes('sticker')) {
+        this.stickers.push(image);
       }
-      return [];
     });
-
-    return new ImagePack(displayName, avatar, usage, attribution, images);
   }
 
-  constructor(displayName, avatar, usage, attribution, images) {
-    this.displayName = displayName;
-    this.avatar = avatar;
-    this.usage = usage;
-    this.attribution = attribution;
-    this.images = images;
+  getImages() {
+    return this.images;
   }
 
-  // Produce a list of emoji in this image pack
   getEmojis() {
-    return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1);
+    return this.emoticons;
   }
 
-  // Produce a list of stickers in this image pack
   getStickers() {
-    return this.images.filter((i) => i.usage.indexOf('sticker') !== -1);
+    return this.stickers;
+  }
+
+  getContent() {
+    return this.content;
+  }
+
+  _updatePackProperty(property, value) {
+    if (this.content.pack === undefined) {
+      this.content.pack = {};
+    }
+    this.content.pack[property] = value;
+    this.applyPack(this.content);
+  }
+
+  setAvatarUrl(avatarUrl) {
+    this._updatePackProperty('avatar_url', avatarUrl);
+  }
+
+  setDisplayName(displayName) {
+    this._updatePackProperty('display_name', displayName);
+  }
+
+  setAttribution(attribution) {
+    this._updatePackProperty('attribution', attribution);
+  }
+
+  setUsage(usage) {
+    this._updatePackProperty('usage', usage);
+  }
+
+  addImage(key, imgContent) {
+    this.content.images = {
+      [key]: imgContent,
+      ...this.content.images,
+    };
+    this.applyImages(this.content);
+  }
+
+  removeImage(key) {
+    if (this.content.images[key] === undefined) return;
+    delete this.content.images[key];
+    this.applyImages(this.content);
+  }
+
+  updateImageKey(key, newKey) {
+    if (this.content.images[key] === undefined) return;
+    const copyImages = {};
+    Object.keys(this.content.images).forEach((imgKey) => {
+      copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
+    });
+    this.content.images = copyImages;
+    this.applyImages(this.content);
+  }
+
+  _updateImageProperty(key, property, value) {
+    if (this.content.images[key] === undefined) return;
+    this.content.images[key][property] = value;
+    this.applyImages(this.content);
+  }
+
+  setImageUrl(key, url) {
+    this._updateImageProperty(key, 'url', url);
+  }
+
+  setImageBody(key, body) {
+    this._updateImageProperty(key, 'body', body);
+  }
+
+  setImageInfo(key, info) {
+    this._updateImageProperty(key, 'info', info);
+  }
+
+  setImageUsage(key, usage) {
+    this._updateImageProperty(key, 'usage', usage);
   }
 }
 
-// Retrieve a list of user emojis
-//
-// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal
-// image pack.
-//
-// Accepts a reference to a matrix client as the only argument
+function getGlobalImagePacks(mx) {
+  const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
+  if (typeof globalContent !== 'object') return [];
+
+  const { rooms } = globalContent;
+  if (typeof rooms !== 'object') return [];
+
+  const roomIds = Object.keys(rooms);
+
+  const packs = roomIds.flatMap((roomId) => {
+    if (typeof rooms[roomId] !== 'object') return [];
+    const room = mx.getRoom(roomId);
+    if (!room) return [];
+    const stateKeys = Object.keys(rooms[roomId]);
+
+    return stateKeys.map((stateKey) => {
+      const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
+      const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
+      if (pack) {
+        pack.displayName ??= room.name;
+        pack.avatarUrl ??= room.getMxcAvatarUrl();
+      }
+      return pack;
+    }).filter((pack) => pack !== null);
+  });
+
+  return packs;
+}
+
 function getUserImagePack(mx) {
   const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
   if (!accountDataEmoji) {
     return null;
   }
 
-  const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content);
-  if (userImagePack) userImagePack.displayName ??= 'Your Emoji';
+  const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
+  userImagePack.displayName ??= 'Personal Emoji';
   return userImagePack;
 }
 
-// Produces a list of all of the emoji packs in a room
-//
-// Returns a list of `ImagePack`s.  This does not include packs in spaces that contain
-// this room.
-function getPacksInRoom(room) {
-  const packs = room.currentState.getStateEvents('im.ponies.room_emotes');
+function getRoomImagePacks(room) {
+  const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
 
-  return packs
-    .map((p) => ImagePack.parsePack(p.event.content, room))
-    .filter((p) => p !== null);
+  return dataEvents
+    .map((data) => {
+      const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
+      if (pack) {
+        pack.displayName ??= room.name;
+        pack.avatarUrl ??= room.getMxcAvatarUrl();
+      }
+      return pack;
+    })
+    .filter((pack) => pack !== null);
 }
 
-// Produce a list of all image packs which should be shown for a given room
-//
-// This includes packs in that room, the user's personal images, and will eventually
-// include the user's enabled global image packs and space-level packs.
-//
-// This differs from getPacksInRoom, as the former only returns packs that are directly in
-// a room, whereas this function returns all packs which should be shown to the user while
-// they are in this room.
-//
-// Packs will be returned in the order that shortcode conflicts should be resolved, with
-// higher priority packs coming first.
-function getRelevantPacks(room) {
+/**
+ * @param {MatrixClient} mx Provide if you want to include user personal/global pack
+ * @param {Room[]} rooms Provide rooms if you want to include rooms pack
+ * @returns {ImagePack[]} packs
+ */
+function getRelevantPacks(mx, rooms) {
+  const userPack = mx ? getUserImagePack(mx) : [];
+  const globalPacks = mx ? getGlobalImagePacks(mx) : [];
+  const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
+  const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
+
   return [].concat(
-    getUserImagePack(room.client) ?? [],
-    getPacksInRoom(room),
+    userPack ?? [],
+    globalPacks,
+    roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
   );
 }
 
-// Returns all user+room emojis and all standard unicode emojis
-//
-// Accepts a reference to a matrix client as the only argument
-//
-// Result is a map from shortcode to the corresponding emoji.  If two emoji share a
-// shortcode, only one will be presented, with priority given to custom emoji.
-//
-// Will eventually be expanded to include all emojis revelant to a room and the user
-function getShortcodeToEmoji(room) {
+function getShortcodeToEmoji(mx, rooms) {
   const allEmoji = new Map();
 
   emojis.forEach((emoji) => {
-    if (emoji.shortcodes.constructor.name === 'Array') {
+    if (Array.isArray(emoji.shortcodes)) {
       emoji.shortcodes.forEach((shortcode) => {
         allEmoji.set(shortcode, emoji);
       });
@@ -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,
 };
index 50c5e5127c780b58b4df2e4346776c9d2c89b1e3..6327734713a7b5eff56a7aa55ae962a5d321e427 100644 (file)
@@ -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 && <GeneralSettings roomId={roomId} />}
             {selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
             {selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
+            {selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
             {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
             {selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
           </div>
@@ -210,7 +218,5 @@ RoomSettings.propTypes = {
   roomId: PropTypes.string.isRequired,
 };
 
-export {
-  RoomSettings as default,
-  tabText,
-};
+export default RoomSettings;
+export { tabText };
index 9c47024da477881ba7c55d15fbd5d727caff62c7..68919aacc38d2ff73b27302dad93c7f2d905251b 100644 (file)
@@ -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();
index 704dd9af482ef914a6486ef18bec743e71b00d62..4a7b2bf78fab0251983bc5aabd5c66e88c5ce03b 100644 (file)
@@ -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 && <RawIcon size="extra-small" src={MarkdownIC} />}
         </div>
         <div ref={rightOptionsRef} className="room-input__option-container">
+          <IconButton
+            onClick={(e) => {
+              openReusableContextMenu(
+                'top',
+                (() => {
+                  const cords = getEventCords(e);
+                  cords.y -= 20;
+                  return cords;
+                })(),
+                (closeMenu) => (
+                  <StickerBoard
+                    roomId={roomId}
+                    onSelect={(data) => {
+                      handleSendSticker(data);
+                      closeMenu();
+                    }}
+                  />
+                ),
+              );
+            }}
+            tooltip="Sticker"
+            src={StickerIC}
+          />
           <IconButton
             onClick={(e) => {
               const cords = getEventCords(e);
index b0f45f41e518cbd9d3496990fa78917b7fb5b94e..b50c9926f0d8c39cda77a1f06f5193751492889e 100644 (file)
@@ -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 (
+    <>
+      <div className="settings-emoji__card"><ImagePackUser /></div>
+      <div className="settings-emoji__card"><ImagePackGlobal /></div>
+    </>
+  );
+}
+
 function SecuritySection() {
   return (
     <div className="settings-security">
@@ -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: () => <NotificationsSection />,
+}, {
+  text: tabText.EMOJI,
+  iconSrc: EmojiIC,
+  disabled: false,
+  render: () => <EmojiSection />,
 }, {
   text: tabText.SECURITY,
   iconSrc: LockIC,
index dac53d7a8e851473d8a85b46636d003a866f1e39..d77e634a41e8e67b78d619d2d2c5d6aaac235dfd 100644 (file)
@@ -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;
 }
 
index 437359930efd48f8bbfa160c5d84c8ca969228e3..2c9d6d46190402ca5e676b1995116bd6134b6b5e 100644 (file)
@@ -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() {
           <div className="space-settings__cards-wrapper">
             {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
             {selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
+            {selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
             {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
           </div>
         </div>
diff --git a/src/app/organisms/sticker-board/StickerBoard.jsx b/src/app/organisms/sticker-board/StickerBoard.jsx
new file mode 100644 (file)
index 0000000..53b7563
--- /dev/null
@@ -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) => (
+    <div className="sticker-board__pack" key={pack.id}>
+      <Text className="sticker-board__pack-header" variant="b2" weight="bold">{pack.displayName ?? 'Unknown'}</Text>
+      <div className="sticker-board__pack-items">
+        {pack.getStickers().map((sticker) => (
+          <img
+            key={sticker.shortcode}
+            className="sticker-board__sticker"
+            src={mx.mxcUrlToHttp(sticker.mxc)}
+            alt={sticker.shortcode}
+            title={sticker.body ?? sticker.shortcode}
+            data-mx-sticker={sticker.mxc}
+          />
+        ))}
+      </div>
+    </div>
+  );
+
+  return (
+    <div className="sticker-board">
+      <div className="sticker-board__container">
+        <ScrollView autoHide>
+          <div
+            onClick={handleOnSelect}
+            className="sticker-board__content"
+          >
+            {
+              packs.length > 0
+                ? packs.map(renderPack)
+                : (
+                  <div className="sticker-board__empty">
+                    <Text>There is no sticker pack.</Text>
+                  </div>
+                )
+            }
+          </div>
+        </ScrollView>
+      </div>
+      <div />
+    </div>
+  );
+}
+StickerBoard.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  onSelect: PropTypes.func.isRequired,
+};
+
+export default StickerBoard;
diff --git a/src/app/organisms/sticker-board/StickerBoard.scss b/src/app/organisms/sticker-board/StickerBoard.scss
new file mode 100644 (file)
index 0000000..be8ad35
--- /dev/null
@@ -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
index 8297bf03cbb964f2b0c5df82cff21f2f0d84261c..41c62d4f9c39380abdd7df5de59dd44046bde0cf 100644 (file)
@@ -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);
   }
index aec2f3da3d11ef3fab3cfbd18ad2056d1beda5d6..2118be56e79cb4bb01b18b6c79e3a07b175d47b4 100644 (file)
@@ -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');
         }
index 882c7bc06f9386a307cb74349861533d491a9c79..2377c8d05fb81c0cc83857126b9365ae7ac482f2 100644 (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');
@@ -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 <img/> tag
+    .forEach((mxidMatch) => {
+      const tag = `<a href="https://matrix.to/#/${mxidMatch[0]}">${userIdsToDisplayNames[mxidMatch[0]]}</a>`;
+
+      formattedText = formattedText.substr(0, mxidMatch.index)
+        + tag
+        + formattedText.substr(mxidMatch.index + mxidMatch[0].length);
+    });
+
+  const SHORTCODE_REGEX = /\B:([\w-]+):\B/g;
+  Array.from(formattedText.matchAll(SHORTCODE_REGEX))
+    .filter((shortcodeMatch) => allEmoji.has(shortcodeMatch[1]))
+    .reverse() /* Reversing the array ensures that indices are preserved as we start replacing */
     .forEach((shortcodeMatch) => {
       const emoji = allEmoji.get(shortcodeMatch[1]);
 
-      // Render the tag that will replace the shortcode
       let tag;
       if (emoji.mxc) {
         tag = `<img data-mx-emoticon="" src="${
@@ -159,7 +155,6 @@ function formatAndEmojifyText(room, text) {
         tag = emoji.unicode;
       }
 
-      // Splice the tag into the text
       formattedText = formattedText.substr(0, shortcodeMatch.index)
         + tag
         + formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length);
@@ -169,10 +164,11 @@ function formatAndEmojifyText(room, text) {
 }
 
 class RoomsInput extends EventEmitter {
-  constructor(mx) {
+  constructor(mx, roomList) {
     super();
 
     this.matrixClient = mx;
+    this.roomList = roomList;
     this.roomIdToInput = new Map();
   }
 
@@ -273,7 +269,9 @@ class RoomsInput extends EventEmitter {
 
       // Apply formatting if relevant
       const formattedBody = formatAndEmojifyText(
-        this.matrixClient.getRoom(roomId),
+        this.matrixClient,
+        this.roomList,
+        roomId,
         input.message,
       );
       if (formattedBody !== input.message) {
@@ -412,7 +410,9 @@ class RoomsInput extends EventEmitter {
 
     // Apply formatting if relevant
     const formattedBody = formatAndEmojifyText(
-      this.matrixClient.getRoom(roomId),
+      this.matrixClient,
+      this.roomList,
+      roomId,
       editedBody,
     );
     if (formattedBody !== editedBody) {
index 3d5383ada6dd78a5b853e04dbc1271615d5e78f9..57891a9d96915ff3cfa36f85cfc36268a592c804 100644 (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;
+  });
+}