added commands support
authorunknown <ajbura@gmail.com>
Sun, 8 Aug 2021 16:26:34 +0000 (21:56 +0530)
committerunknown <ajbura@gmail.com>
Sun, 8 Aug 2021 16:26:34 +0000 (21:56 +0530)
12 files changed:
public/res/ic/outlined/cmd.svg [new file with mode: 0644]
public/res/ic/outlined/markdown.svg [new file with mode: 0644]
src/app/organisms/channel/ChannelViewCmdBar.jsx
src/app/organisms/channel/ChannelViewCmdBar.scss
src/app/organisms/channel/ChannelViewHeader.jsx
src/app/organisms/channel/ChannelViewInput.jsx
src/app/organisms/channel/ChannelViewInput.scss
src/app/organisms/emoji-board/EmojiBoard.jsx
src/app/organisms/emoji-board/emoji.js
src/client/action/settings.js [new file with mode: 0644]
src/client/state/cons.js
src/client/state/settings.js

diff --git a/public/res/ic/outlined/cmd.svg b/public/res/ic/outlined/cmd.svg
new file mode 100644 (file)
index 0000000..75ae0d9
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+        viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+       <path d="M20,4H4C2.9,4,2,4.9,2,6v12c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V6C22,4.9,21.1,4,20,4z M20,18H4V6h16V18z"/>
+       <polygon points="7.5,16.5 12.1,12 7.5,7.5 6.5,8.5 9.9,12 6.5,15.5       "/>
+       <rect x="13" y="14.5" width="5" height="1.5"/>
+</g>
+</svg>
diff --git a/public/res/ic/outlined/markdown.svg b/public/res/ic/outlined/markdown.svg
new file mode 100644 (file)
index 0000000..775afbf
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+        viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+       <polygon points="12,7 12,7 8,11 4,7 4,7 4,7 2,7 2,17 4,17 4,9.8 8,13.8 12,9.8 12,17 14,17 14,7 12,7     "/>
+       <path d="M20,14V7h-2v7h-2l3,3c0.1,0,0.5-0.4,1-0.9c0.9-0.9,2-2.1,2-2.1H20z"/>
+</g>
+</svg>
index b7006c8c1a8674426c9752c70ec652318db26d08..6229b376fb5666add69b1f28ca2d236d2712ba60 100644 (file)
 import React, { useState, useEffect } from 'react';
 import PropTypes from 'prop-types';
 import './ChannelViewCmdBar.scss';
+import Fuse from 'fuse.js';
 
 import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
+import { toggleMarkdown } from '../../../client/action/settings';
+import * as roomActions from '../../../client/action/room';
+import {
+  selectRoom,
+  openCreateChannel,
+  openPublicChannels,
+  openInviteUser,
+} from '../../../client/action/navigation';
+import { searchEmoji } from '../emoji-board/emoji';
 
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import IconButton from '../../atoms/button/IconButton';
+import ContextMenu, { MenuHeader } from '../../atoms/context-menu/ContextMenu';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import SettingTile from '../../molecules/setting-tile/SettingTile';
 import TimelineChange from '../../molecules/message/TimelineChange';
 
+import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
+
 import { getUsersActionJsx } from './common';
 
-function ChannelViewCmdBar({ roomId, roomTimeline, viewEvent }) {
+const commands = [{
+  name: 'markdown',
+  description: 'Toggle markdown for messages.',
+  exe: () => toggleMarkdown(),
+}, {
+  name: 'startDM',
+  isOptions: true,
+  description: 'Start direct message with user. Example: /startDM/@johndoe.matrix.org',
+  exe: (roomId, searchTerm) => openInviteUser(undefined, searchTerm),
+}, {
+  name: 'createChannel',
+  description: 'Create new channel',
+  exe: () => openCreateChannel(),
+}, {
+  name: 'join',
+  isOptions: true,
+  description: 'Join channel with alias. Example: /join/#cinny:matrix.org',
+  exe: (roomId, searchTerm) => openPublicChannels(searchTerm),
+}, {
+  name: 'leave',
+  description: 'Leave current channel',
+  exe: (roomId) => roomActions.leave(roomId),
+}, {
+  name: 'invite',
+  isOptions: true,
+  description: 'Invite user to room. Example: /invite/@johndoe:matrix.org',
+  exe: (roomId, searchTerm) => openInviteUser(roomId, searchTerm),
+}];
+
+function CmdHelp() {
+  return (
+    <ContextMenu
+      placement="top"
+      content={(
+        <>
+          <MenuHeader>General command</MenuHeader>
+          <Text variant="b2">/command_name</Text>
+          <MenuHeader>Go-to commands</MenuHeader>
+          <Text variant="b2">{'>*space_name'}</Text>
+          <Text variant="b2">{'>#channel_name'}</Text>
+          <Text variant="b2">{'>@people_name'}</Text>
+          <MenuHeader>Autofill command</MenuHeader>
+          <Text variant="b2">:emoji_name:</Text>
+        </>
+      )}
+      render={(toggleMenu) => (
+        <IconButton
+          src={CmdIC}
+          size="extra-small"
+          onClick={toggleMenu}
+          tooltip="Commands"
+        />
+      )}
+    />
+  );
+}
+
+function ViewCmd() {
+  function renderAllCmds() {
+    return commands.map((command) => (
+      <SettingTile
+        key={command.name}
+        title={command.name}
+        content={(<Text variant="b3">{command.description}</Text>)}
+      />
+    ));
+  }
+  return (
+    <ContextMenu
+      maxWidth={250}
+      placement="top"
+      content={(
+        <>
+          <MenuHeader>General commands</MenuHeader>
+          {renderAllCmds()}
+        </>
+      )}
+      render={(toggleMenu) => (
+        <span>
+          <Button onClick={toggleMenu}><span className="text text-b3">View all</span></Button>
+        </span>
+      )}
+    />
+  );
+}
+
+function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
   const [followingMembers, setFollowingMembers] = useState([]);
   const mx = initMatrix.matrixClient;
 
@@ -26,9 +130,7 @@ function ChannelViewCmdBar({ roomId, roomTimeline, viewEvent }) {
     setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
   }
 
-  useEffect(() => {
-    updateFollowingMembers();
-  }, [roomId]);
+  useEffect(() => updateFollowingMembers(), [roomId]);
 
   useEffect(() => {
     roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
@@ -39,17 +141,264 @@ function ChannelViewCmdBar({ roomId, roomTimeline, viewEvent }) {
     };
   }, [roomTimeline]);
 
+  return followingMembers.length !== 0 && (
+    <TimelineChange
+      variant="follow"
+      content={getUsersActionJsx(followingMembers, 'following the conversation.')}
+      time=""
+    />
+  );
+}
+
+FollowingMembers.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  roomTimeline: PropTypes.shape({}).isRequired,
+  viewEvent: PropTypes.shape({}).isRequired,
+};
+
+function getCmdActivationMessage(prefix) {
+  function genMessage(prime, secondary) {
+    return (
+      <>
+        <span>{prime}</span>
+        <span>{secondary}</span>
+      </>
+    );
+  }
+  const cmd = {
+    '/': () => genMessage('General command mode activated. ', 'Type command name for suggestions.'),
+    '>*': () => genMessage('Go-to command mode activated. ', 'Type space name for suggestions.'),
+    '>#': () => genMessage('Go-to command mode activated. ', 'Type channel name for suggestions.'),
+    '>@': () => genMessage('Go-to command mode activated. ', 'Type people name for suggestions.'),
+    ':': () => genMessage('Emoji autofill command mode activated. ', 'Type emoji shortcut for suggestions.'),
+  };
+  return cmd[prefix]?.();
+}
+
+function CmdItem({ onClick, children }) {
   return (
-    <div className="channel-cmd-bar">
-      {
-        followingMembers.length !== 0 && (
-          <TimelineChange
-            variant="follow"
-            content={getUsersActionJsx(followingMembers, 'following the conversation.')}
-            time=""
-          />
-        )
+    <button className="cmd-item" onClick={onClick} type="button">
+      {children}
+    </button>
+  );
+}
+CmdItem.propTypes = {
+  onClick: PropTypes.func.isRequired,
+  children: PropTypes.node.isRequired,
+};
+
+function searchInRoomIds(roomIds, term) {
+  const rooms = roomIds.map((rId) => {
+    const room = initMatrix.matrixClient.getRoom(rId);
+    return {
+      name: room.name,
+      roomId: room.roomId,
+    };
+  });
+  const fuse = new Fuse(rooms, {
+    includeScore: true,
+    keys: ['name'],
+    threshold: '0.3',
+  });
+  return fuse.search(term);
+}
+
+function searchCommands(term) {
+  const fuse = new Fuse(commands, {
+    includeScore: true,
+    keys: ['name'],
+    threshold: '0.3',
+  });
+  return fuse.search(term);
+}
+
+let perfectMatchCmd = null;
+function getCmdSuggestions({ prefix, slug }, fireCmd, viewEvent) {
+  function getRoomsSuggestion(cmdPrefix, rooms, roomSlug) {
+    const result = searchInRoomIds(rooms, roomSlug);
+    if (result.length === 0) viewEvent.emit('cmd_error');
+    perfectMatchCmd = {
+      prefix: cmdPrefix,
+      slug: roomSlug,
+      result: result[0]?.item || null,
+    };
+    return result.map((finding) => (
+      <CmdItem
+        key={finding.item.roomId}
+        onClick={() => {
+          fireCmd({
+            prefix: cmdPrefix,
+            slug: roomSlug,
+            result: finding.item,
+          });
+        }}
+      >
+        <Text variant="b2">{finding.item.name}</Text>
+      </CmdItem>
+    ));
+  }
+
+  function getGenCmdSuggestions(cmdPrefix, cmdSlug) {
+    const cmdSlugParts = cmdSlug.split('/');
+    const cmdSlugOption = cmdSlugParts[1];
+    const result = searchCommands(cmdSlugParts[0]);
+    if (result.length === 0) viewEvent.emit('cmd_error');
+    perfectMatchCmd = {
+      prefix: cmdPrefix,
+      slug: cmdSlug,
+      option: cmdSlugOption,
+      result: result[0]?.item || null,
+    };
+    return result.map((finding) => {
+      let option = '';
+      if (finding.item.isOptions) {
+        if (typeof cmdSlugOption === 'string') option = `/${cmdSlugOption}`;
+        else option = '/?';
       }
+      return (
+        <CmdItem
+          key={finding.item.name}
+          onClick={() => {
+            fireCmd({
+              prefix: cmdPrefix,
+              slug: cmdSlug,
+              option: cmdSlugOption,
+              result: finding.item,
+            });
+          }}
+        >
+          <Text variant="b2">{`${finding.item.name}${option}`}</Text>
+        </CmdItem>
+      );
+    });
+  }
+
+  function getEmojiSuggestion(emPrefix, shortcutSlug) {
+    const result = searchEmoji(shortcutSlug);
+    if (result.length === 0) viewEvent.emit('cmd_error');
+    perfectMatchCmd = {
+      prefix: emPrefix,
+      slug: shortcutSlug,
+      result: result[0]?.item || null,
+    };
+    return result.map((finding) => (
+      <CmdItem
+        key={finding.item.hexcode}
+        onClick={() => fireCmd({
+          prefix: emPrefix,
+          slug: shortcutSlug,
+          result: finding.item,
+        })}
+      >
+        <Text variant="b2">{finding.item.unicode}</Text>
+      </CmdItem>
+    ));
+  }
+
+  const { roomList } = initMatrix;
+  const cmd = {
+    '/': (command) => getGenCmdSuggestions(prefix, command),
+    '>*': (space) => getRoomsSuggestion(prefix, [...roomList.spaces], space),
+    '>#': (channel) => getRoomsSuggestion(prefix, [...roomList.rooms], channel),
+    '>@': (people) => getRoomsSuggestion(prefix, [...roomList.directs], people),
+    ':': (emojiShortcut) => getEmojiSuggestion(prefix, emojiShortcut),
+  };
+  return cmd[prefix]?.(slug);
+}
+
+function ChannelViewCmdBar({ roomId, roomTimeline, viewEvent }) {
+  const [cmd, setCmd] = useState(null);
+
+  function processCmd(prefix, slug) {
+    setCmd({ prefix, slug });
+  }
+  function activateCmd(prefix) {
+    setCmd({ prefix });
+    perfectMatchCmd = null;
+  }
+  function deactivateCmd() {
+    setCmd(null);
+    perfectMatchCmd = null;
+  }
+  function fireCmd(myCmd) {
+    if (myCmd.prefix.match(/^>[*#@]$/)) {
+      selectRoom(myCmd.result.roomId);
+      viewEvent.emit('cmd_fired');
+    }
+    if (myCmd.prefix === '/') {
+      myCmd.result.exe(roomId, myCmd.option);
+      viewEvent.emit('cmd_fired');
+    }
+    if (myCmd.prefix === ':') {
+      viewEvent.emit('cmd_fired', {
+        replace: myCmd.result.unicode,
+      });
+    }
+    deactivateCmd();
+  }
+  function executeCmd() {
+    if (perfectMatchCmd === null) return;
+    if (perfectMatchCmd.result === null) return;
+    fireCmd(perfectMatchCmd);
+  }
+  function errorCmd() {
+    setCmd({ error: 'No suggestion found.' });
+  }
+
+  useEffect(() => {
+    viewEvent.on('cmd_activate', activateCmd);
+    viewEvent.on('cmd_process', processCmd);
+    viewEvent.on('cmd_deactivate', deactivateCmd);
+    viewEvent.on('cmd_exe', executeCmd);
+    viewEvent.on('cmd_error', errorCmd);
+    return () => {
+      deactivateCmd();
+      viewEvent.removeListener('cmd_activate', activateCmd);
+      viewEvent.removeListener('cmd_process', processCmd);
+      viewEvent.removeListener('cmd_deactivate', deactivateCmd);
+      viewEvent.removeListener('cmd_exe', executeCmd);
+      viewEvent.removeListener('cmd_error', errorCmd);
+    };
+  }, [roomId]);
+
+  if (cmd !== null && typeof cmd.error !== 'undefined') {
+    return (
+      <div className="cmd-bar">
+        <div className="cmd-bar__info">
+          <div className="cmd-bar__info-indicator--error" />
+        </div>
+        <div className="cmd-bar__content">
+          <Text className="cmd-bar__content-error" variant="b2">{cmd.error}</Text>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="cmd-bar">
+      <div className="cmd-bar__info">
+        {cmd === null && <CmdHelp />}
+        {cmd !== null && typeof cmd.slug === 'undefined' && <div className="cmd-bar__info-indicator" /> }
+        {cmd !== null && typeof cmd.slug === 'string' && <Text variant="b3">TAB</Text>}
+      </div>
+      <div className="cmd-bar__content">
+        {cmd === null && (
+          <FollowingMembers
+            roomId={roomId}
+            roomTimeline={roomTimeline}
+            viewEvent={viewEvent}
+          />
+        )}
+        {cmd !== null && typeof cmd.slug === 'undefined' && <Text className="cmd-bar__content-help" variant="b2">{getCmdActivationMessage(cmd.prefix)}</Text>}
+        {cmd !== null && typeof cmd.slug === 'string' && (
+          <ScrollView horizontal vertical={false} invisible>
+            <div className="cmd-bar__content__suggestions">{getCmdSuggestions(cmd, fireCmd, viewEvent)}</div>
+          </ScrollView>
+        )}
+      </div>
+      <div className="cmd-bar__more">
+        {cmd !== null && cmd.prefix === '/' && <ViewCmd />}
+      </div>
     </div>
   );
 }
index 7c14f74ef1004c2c1c4f135e9469c839ddb7a555..43450fe40f6b84d88fec301e2c99b599a01c2b13 100644 (file)
-.channel-cmd-bar {
+.overflow-ellipsis {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.cmd-bar {
   --cmd-bar-height: 28px;
   min-height: var(--cmd-bar-height);
+  display: flex;
+
+  &__info {
+    display: flex;
+    width: calc(2 * var(--sp-extra-loose));
+    padding-left: var(--sp-ultra-tight);
+    [dir=rtl] & {
+      padding-left: 0;
+      padding-right: var(--sp-ultra-tight);
+    }
+
+    & > * {
+      margin: auto;
+    }
+
+    & .ic-btn-surface {
+      padding: 0;
+      & .ic-raw {
+        background-color: var(--tc-surface-low);
+      }
+    }
+    & .context-menu .text-b2 {
+      margin: var(--sp-extra-tight) var(--sp-tight);
+    }
+
+    &-indicator,
+    &-indicator--error {
+      width: 8px;
+      height: 8px;
+      border-radius: 50%;
+      background-color: var(--bg-positive);
+    }
+    &-indicator--error {
+      background-color: var(--bg-danger);
+    }
+  }
+
+  &__content {
+    min-width: 0;
+    flex: 1;
+    display: flex;
+
+    &-help,
+    &-error {
+      @extend .overflow-ellipsis;
+      align-self: center;
+      span {
+        color: var(--tc-surface-low);
+        &:first-child {
+          color: var(--tc-surface-normal)
+        }
+      }
+    }
+    &-error {
+      color: var(--bg-danger);
+    }
+    &__suggestions {
+      display: flex;
+      height: 100%;
+      white-space: nowrap;
+    }
+  }
+  &__more {
+    display: flex;
+    & button {
+      min-width: 0;
+      height: 100%;
+      margin: 0 var(--sp-normal);
+      padding: 0 var(--sp-extra-tight);
+      box-shadow: none;
+      border-radius: var(--bo-radius) var(--bo-radius) 0 0;
+      & .text {
+        color: var(--tc-surface-normal);
+      }
+    }
+    & .setting-tile {
+      margin: var(--sp-tight);
+    }
+  }
 
   & .timeline-change {
+    width: 100%;
     justify-content: flex-end;
     padding: var(--sp-ultra-tight) var(--sp-normal);
+    border-radius: var(--bo-radius) var(--bo-radius) 0 0;
 
     &__content {
       margin: 0;
       flex: unset;
       & > .text {
-        overflow: hidden;
-        white-space: nowrap;
-        text-overflow: ellipsis;
+        @extend .overflow-ellipsis;
         & b {
           color: var(--tc-surface-normal);
         }
       }
     }
   }
+}
+
+.cmd-item {
+  --cmd-item-bar: inset 0 -2px 0 0 var(--bg-caution);
+
+  display: inline-flex;
+  align-items: center;
+  margin-right: var(--sp-extra-tight);
+  padding: 0 var(--sp-extra-tight);
+  height: 100%;
+  border-radius: var(--bo-radius) var(--bo-radius) 0 0;
+  cursor: pointer;
+
+  [dir=rtl] & {
+    margin-right: 0;
+    margin-left: var(--sp-extra-tight);
+  }
+
+  &:hover {
+    background-color: var(--bg-caution-hover);
+  }
+  &:focus {
+    background-color: var(--bg-caution-hover);
+    box-shadow: var(--cmd-item-bar);
+    border-bottom: 2px solid transparent;
+    outline: none;
+  }
 }
\ No newline at end of file
index a9d4551612fcf35a712fba9c6d8aae050083525b..b9f56d8f7f7ad655db633796b9011de7fb299e90 100644 (file)
@@ -21,7 +21,6 @@ function ChannelViewHeader({ roomId }) {
   const mx = initMatrix.matrixClient;
   const avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
   const roomName = mx.getRoom(roomId).name;
-  const isDM = initMatrix.roomList.directs.has(roomId);
   const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
 
   return (
@@ -46,7 +45,7 @@ function ChannelViewHeader({ roomId }) {
             >
               Invite
             </MenuItem>
-            <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId, isDM)}>Leave</MenuItem>
+            <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId)}>Leave</MenuItem>
           </>
         )}
         render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
index 67b002f4ac461be395d0bd669a78a9686fb5b271..0a40117e46e6035dcc7a203149446010a87c8336 100644 (file)
@@ -7,6 +7,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 { bytesToSize } from '../../../util/common';
 
 import Text from '../../atoms/text/Text';
@@ -22,23 +23,36 @@ import SendIC from '../../../../public/res/ic/outlined/send.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';
+import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg';
 import FileIC from '../../../../public/res/ic/outlined/file.svg';
 
+const CMD_REGEX = /(\/|>[#*@]|:)(\S*)$/;
 let isTyping = false;
+let isCmdActivated = false;
+let cmdCursorPos = null;
 function ChannelViewInput({
   roomId, roomTimeline, timelineScroll, viewEvent,
 }) {
   const [attachment, setAttachment] = useState(null);
+  const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
 
   const textAreaRef = useRef(null);
   const inputBaseRef = useRef(null);
   const uploadInputRef = useRef(null);
   const uploadProgressRef = useRef(null);
+  const rightOptionsRef = useRef(null);
 
   const TYPING_TIMEOUT = 5000;
   const mx = initMatrix.matrixClient;
   const { roomsInput } = initMatrix;
 
+  useEffect(() => {
+    settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
+    return () => {
+      settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
+    };
+  }, []);
+
   const sendIsTyping = (isT) => {
     mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
     isTyping = isT;
@@ -63,10 +77,58 @@ function ChannelViewInput({
     uploadInputRef.current.value = null;
   }
 
+  function rightOptionsA11Y(A11Y) {
+    const rightOptions = rightOptionsRef.current.children;
+    for (let index = 0; index < rightOptions.length; index += 1) {
+      rightOptions[index].disabled = !A11Y;
+    }
+  }
+
+  function activateCmd(prefix) {
+    isCmdActivated = true;
+    inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-positive)';
+    rightOptionsA11Y(false);
+    viewEvent.emit('cmd_activate', prefix);
+  }
+  function deactivateCmd() {
+    if (inputBaseRef.current !== null) {
+      inputBaseRef.current.style.boxShadow = 'var(--bs-surface-border)';
+      rightOptionsA11Y(true);
+    }
+    isCmdActivated = false;
+    cmdCursorPos = null;
+  }
+  function errorCmd() {
+    inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-danger)';
+  }
+  function setCursorPosition(pos) {
+    setTimeout(() => {
+      textAreaRef.current.focus();
+      textAreaRef.current.setSelectionRange(pos, pos);
+    }, 0);
+  }
+  function replaceCmdWith(msg, cursor, replacement) {
+    if (msg === null) return null;
+    const targetInput = msg.slice(0, cursor);
+    const cmdParts = targetInput.match(CMD_REGEX);
+    const leadingInput = msg.slice(0, cmdParts.index);
+    if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
+    return leadingInput + replacement + msg.slice(cursor);
+  }
+  function firedCmd(cmdData) {
+    const msg = textAreaRef.current.value;
+    textAreaRef.current.value = replaceCmdWith(
+      msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
+    );
+    deactivateCmd();
+  }
+
   useEffect(() => {
     roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
     roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
     roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
+    viewEvent.on('cmd_error', errorCmd);
+    viewEvent.on('cmd_fired', firedCmd);
     if (textAreaRef?.current !== null) {
       isTyping = false;
       textAreaRef.current.focus();
@@ -77,6 +139,9 @@ function ChannelViewInput({
       roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
       roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
       roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
+      viewEvent.removeListener('cmd_error', errorCmd);
+      viewEvent.removeListener('cmd_fired', firedCmd);
+      if (isCmdActivated) deactivateCmd();
       if (textAreaRef?.current === null) return;
 
       const msg = textAreaRef.current.value;
@@ -90,6 +155,11 @@ function ChannelViewInput({
   }, [roomId]);
 
   async function sendMessage() {
+    if (isCmdActivated) {
+      viewEvent.emit('cmd_exe');
+      return;
+    }
+
     const msgBody = textAreaRef.current.value;
     if (roomsInput.isSending(roomId)) return;
     if (msgBody.trim() === '' && attachment === null) return;
@@ -124,9 +194,39 @@ function ChannelViewInput({
     }
   }
 
+  function getCursorPosition() {
+    return textAreaRef.current.selectionStart;
+  }
+
+  function recognizeCmd(rawInput) {
+    const cursor = getCursorPosition();
+    const targetInput = rawInput.slice(0, cursor);
+
+    const cmdParts = targetInput.match(CMD_REGEX);
+    if (cmdParts === null) {
+      if (isCmdActivated) {
+        deactivateCmd();
+        viewEvent.emit('cmd_deactivate');
+      }
+      return;
+    }
+    const cmdPrefix = cmdParts[1];
+    const cmdSlug = cmdParts[2];
+
+    cmdCursorPos = cursor;
+    if (cmdSlug === '') {
+      activateCmd(cmdPrefix);
+      return;
+    }
+    if (!isCmdActivated) activateCmd(cmdPrefix);
+    inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-caution)';
+    viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
+  }
+
   function handleMsgTyping(e) {
     const msg = e.target.value;
-    processTyping(msg);
+    recognizeCmd(e.target.value);
+    if (!isCmdActivated) processTyping(msg);
   }
 
   function handleKeyDown(e) {
@@ -172,8 +272,9 @@ function ChannelViewInput({
               />
             </Text>
           </ScrollView>
+          {isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
         </div>
-        <div className="channel-input__option-container">
+        <div ref={rightOptionsRef} className="channel-input__option-container">
           <ContextMenu
             placement="top"
             content={(
index c5565e5efecd348ca70465468a9c086e13ecf225..915aa6d96c69ecf6e377e2c7d7f19d5a3328b159 100644 (file)
 
     & > .ic-raw {
       transform: scale(0.8);
-      margin-left: var(--sp-extra-tight);
-      [dir=rtl] & {
-        margin-left: 0;
-        margin-right: var(--sp-extra-tight);
-      }
+      margin: 0 var(--sp-extra-tight);
     }
     & .scrollbar {
       max-height: 50vh;
+      flex: 1;
+
+      &:first-child {
+        margin-left: var(--sp-tight);
+        [dir=rtl] & {
+          margin-left: 0;
+          margin-right: var(--sp-tight);
+        }
+      }
     }
   }
 
@@ -44,7 +49,7 @@
       width: 100%;
       min-width: 0;
       min-height: 100%;
-      padding: var(--sp-ultra-tight) calc(var(--sp-tight) - 2px);
+      padding: var(--sp-ultra-tight) 0;
 
       &::placeholder {
         color: var(--tc-surface-low);
index e4c2e75e4d4b90195b0044c4fb56c798b789c5bd..5428eaa8d414f41ae1577ee6336f0c9fb0cb4362 100644 (file)
@@ -88,7 +88,7 @@ function SearchedEmoji() {
       setSearchedEmojis([]);
       return;
     }
-    setSearchedEmojis(searchEmoji(term));
+    setSearchedEmojis(searchEmoji(term).map((finding) => finding.item));
   }
 
   useEffect(() => {
index e759d59d97105c709d8a7940313d8232995f97f4..821b0a2dc4ad276b52f01cd2f7b602f24c0dd0e3 100644 (file)
@@ -68,7 +68,7 @@ function searchEmoji(term) {
 
   let result = fuse.search(term);
   if (result.length > 20) result = result.slice(0, 20);
-  return result.map((finding) => finding.item);
+  return result;
 }
 
 export {
diff --git a/src/client/action/settings.js b/src/client/action/settings.js
new file mode 100644 (file)
index 0000000..1664eb8
--- /dev/null
@@ -0,0 +1,12 @@
+import appDispatcher from '../dispatcher';
+import cons from '../state/cons';
+
+function toggleMarkdown() {
+  appDispatcher.dispatch({
+    type: cons.actions.settings.TOGGLE_MARKDOWN,
+  });
+}
+
+export {
+  toggleMarkdown,
+};
index 9ecd1df2ce369494af771d7ccf2e26fe9d930bd6..9b30031fb70f83671cb7a9b23cc68bc093bd1859 100644 (file)
@@ -22,9 +22,12 @@ const cons = {
       LEAVE: 'LEAVE',
       CREATE: 'CREATE',
       error: {
-        CREATE: 'CREATE',
+        CREATE: 'ERROR_CREATE',
       },
     },
+    settings: {
+      TOGGLE_MARKDOWN: 'TOGGLE_MARKDOWN',
+    },
   },
   events: {
     navigation: {
@@ -57,6 +60,9 @@ const cons = {
       FILE_UPLOAD_CANCELED: 'FILE_UPLOAD_CANCELED',
       ATTACHMENT_CANCELED: 'ATTACHMENT_CANCELED',
     },
+    settings: {
+      MARKDOWN_TOGGLED: 'MARKDOWN_TOGGLED',
+    },
   },
 };
 
index 1b9dfc2ed1cbdb3c10a472e8662511555a6e6bfc..fcbf4bea7edae0162fd63b8290dc1c344c4c05dd 100644 (file)
@@ -1,15 +1,36 @@
-class Settings {
+import EventEmitter from 'events';
+import appDispatcher from '../dispatcher';
+
+import cons from './cons';
+
+function getSettings() {
+  const settings = localStorage.getItem('settings');
+  if (settings === null) return null;
+  return JSON.parse(settings);
+}
+
+function setSettings(key, value) {
+  let settings = getSettings();
+  if (settings === null) settings = {};
+  settings[key] = value;
+  localStorage.setItem('settings', JSON.stringify(settings));
+}
+
+class Settings extends EventEmitter {
   constructor() {
+    super();
+
     this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme'];
     this.themeIndex = this.getThemeIndex();
+
+    this.isMarkdown = this.getIsMarkdown();
   }
 
   getThemeIndex() {
     if (typeof this.themeIndex === 'number') return this.themeIndex;
 
-    let settings = localStorage.getItem('settings');
+    const settings = getSettings();
     if (settings === null) return 0;
-    settings = JSON.parse(settings);
     if (typeof settings.themeIndex === 'undefined') return 0;
     // eslint-disable-next-line radix
     return parseInt(settings.themeIndex);
@@ -26,11 +47,33 @@ class Settings {
       appBody.classList.remove(themeName);
     });
     if (this.themes[themeIndex] !== '') appBody.classList.add(this.themes[themeIndex]);
-    localStorage.setItem('settings', JSON.stringify({ themeIndex }));
+    setSettings('themeIndex', themeIndex);
     this.themeIndex = themeIndex;
   }
+
+  getIsMarkdown() {
+    if (typeof this.isMarkdown === 'boolean') return this.isMarkdown;
+
+    const settings = getSettings();
+    if (settings === null) return false;
+    if (typeof settings.isMarkdown === 'undefined') return false;
+    return settings.isMarkdown;
+  }
+
+  setter(action) {
+    const actions = {
+      [cons.actions.settings.TOGGLE_MARKDOWN]: () => {
+        this.isMarkdown = !this.isMarkdown;
+        setSettings('isMarkdown', this.isMarkdown);
+        this.emit(cons.events.settings.MARKDOWN_TOGGLED, this.isMarkdown);
+      },
+    };
+
+    actions[action.type]?.();
+  }
 }
 
 const settings = new Settings();
+appDispatcher.register(settings.setter.bind(settings));
 
 export default settings;