Renamed channels to rooms (#30)
authorunknown <ajbura@gmail.com>
Tue, 31 Aug 2021 13:13:31 +0000 (18:43 +0530)
committerunknown <ajbura@gmail.com>
Tue, 31 Aug 2021 13:13:31 +0000 (18:43 +0530)
68 files changed:
src/app/molecules/channel-intro/ChannelIntro.jsx [deleted file]
src/app/molecules/channel-intro/ChannelIntro.scss [deleted file]
src/app/molecules/channel-selector/ChannelSelector.jsx [deleted file]
src/app/molecules/channel-selector/ChannelSelector.scss [deleted file]
src/app/molecules/channel-tile/ChannelTile.jsx [deleted file]
src/app/molecules/channel-tile/ChannelTile.scss [deleted file]
src/app/molecules/room-intro/RoomIntro.jsx [new file with mode: 0644]
src/app/molecules/room-intro/RoomIntro.scss [new file with mode: 0644]
src/app/molecules/room-selector/RoomSelector.jsx [new file with mode: 0644]
src/app/molecules/room-selector/RoomSelector.scss [new file with mode: 0644]
src/app/molecules/room-tile/RoomTile.jsx [new file with mode: 0644]
src/app/molecules/room-tile/RoomTile.scss [new file with mode: 0644]
src/app/organisms/channel/Channel.jsx [deleted file]
src/app/organisms/channel/Channel.scss [deleted file]
src/app/organisms/channel/ChannelView.jsx [deleted file]
src/app/organisms/channel/ChannelView.scss [deleted file]
src/app/organisms/channel/ChannelViewCmdBar.jsx [deleted file]
src/app/organisms/channel/ChannelViewCmdBar.scss [deleted file]
src/app/organisms/channel/ChannelViewContent.jsx [deleted file]
src/app/organisms/channel/ChannelViewContent.scss [deleted file]
src/app/organisms/channel/ChannelViewFloating.jsx [deleted file]
src/app/organisms/channel/ChannelViewFloating.scss [deleted file]
src/app/organisms/channel/ChannelViewHeader.jsx [deleted file]
src/app/organisms/channel/ChannelViewInput.jsx [deleted file]
src/app/organisms/channel/ChannelViewInput.scss [deleted file]
src/app/organisms/channel/PeopleDrawer.jsx [deleted file]
src/app/organisms/channel/PeopleDrawer.scss [deleted file]
src/app/organisms/channel/common.jsx [deleted file]
src/app/organisms/create-channel/CreateChannel.jsx [deleted file]
src/app/organisms/create-channel/CreateChannel.scss [deleted file]
src/app/organisms/create-room/CreateRoom.jsx [new file with mode: 0644]
src/app/organisms/create-room/CreateRoom.scss [new file with mode: 0644]
src/app/organisms/invite-list/InviteList.jsx
src/app/organisms/invite-list/InviteList.scss
src/app/organisms/invite-user/InviteUser.jsx
src/app/organisms/invite-user/InviteUser.scss
src/app/organisms/navigation/Drawer.jsx
src/app/organisms/navigation/Drawer.scss
src/app/organisms/navigation/DrawerHeader.jsx
src/app/organisms/navigation/Home.jsx
src/app/organisms/navigation/Selector.jsx
src/app/organisms/navigation/SideBar.jsx
src/app/organisms/public-channels/PublicChannels.jsx [deleted file]
src/app/organisms/public-channels/PublicChannels.scss [deleted file]
src/app/organisms/public-rooms/PublicRooms.jsx [new file with mode: 0644]
src/app/organisms/public-rooms/PublicRooms.scss [new file with mode: 0644]
src/app/organisms/pw/Windows.jsx
src/app/organisms/room/PeopleDrawer.jsx [new file with mode: 0644]
src/app/organisms/room/PeopleDrawer.scss [new file with mode: 0644]
src/app/organisms/room/Room.jsx [new file with mode: 0644]
src/app/organisms/room/Room.scss [new file with mode: 0644]
src/app/organisms/room/RoomView.jsx [new file with mode: 0644]
src/app/organisms/room/RoomView.scss [new file with mode: 0644]
src/app/organisms/room/RoomViewCmdBar.jsx [new file with mode: 0644]
src/app/organisms/room/RoomViewCmdBar.scss [new file with mode: 0644]
src/app/organisms/room/RoomViewContent.jsx [new file with mode: 0644]
src/app/organisms/room/RoomViewContent.scss [new file with mode: 0644]
src/app/organisms/room/RoomViewFloating.jsx [new file with mode: 0644]
src/app/organisms/room/RoomViewFloating.scss [new file with mode: 0644]
src/app/organisms/room/RoomViewHeader.jsx [new file with mode: 0644]
src/app/organisms/room/RoomViewInput.jsx [new file with mode: 0644]
src/app/organisms/room/RoomViewInput.scss [new file with mode: 0644]
src/app/organisms/room/common.jsx [new file with mode: 0644]
src/app/templates/client/Client.jsx
src/app/templates/client/Client.scss
src/client/action/navigation.js
src/client/state/cons.js
src/client/state/navigation.js

diff --git a/src/app/molecules/channel-intro/ChannelIntro.jsx b/src/app/molecules/channel-intro/ChannelIntro.jsx
deleted file mode 100644 (file)
index 362dc14..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import './ChannelIntro.scss';
-
-import Linkify from 'linkifyjs/react';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import Avatar from '../../atoms/avatar/Avatar';
-
-function linkifyContent(content) {
-  return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
-}
-
-function ChannelIntro({
-  roomId, avatarSrc, name, heading, desc, time,
-}) {
-  return (
-    <div className="channel-intro">
-      <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={colorMXID(roomId)} size="large" />
-      <div className="channel-intro__content">
-        <Text className="channel-intro__name" variant="h1">{heading}</Text>
-        <Text className="channel-intro__desc" variant="b1">{linkifyContent(desc)}</Text>
-        { time !== null && <Text className="channel-intro__time" variant="b3">{time}</Text>}
-      </div>
-    </div>
-  );
-}
-
-ChannelIntro.defaultProps = {
-  avatarSrc: false,
-  time: null,
-};
-
-ChannelIntro.propTypes = {
-  roomId: PropTypes.string.isRequired,
-  avatarSrc: PropTypes.oneOfType([
-    PropTypes.string,
-    PropTypes.bool,
-  ]),
-  name: PropTypes.string.isRequired,
-  heading: PropTypes.string.isRequired,
-  desc: PropTypes.string.isRequired,
-  time: PropTypes.string,
-};
-
-export default ChannelIntro;
diff --git a/src/app/molecules/channel-intro/ChannelIntro.scss b/src/app/molecules/channel-intro/ChannelIntro.scss
deleted file mode 100644 (file)
index 35186af..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-.channel-intro {
-  margin-top: calc(2 * var(--sp-extra-loose));
-  margin-bottom: var(--sp-extra-loose);
-  padding-left: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
-  padding-right: var(--sp-extra-tight);
-
-  [dir=rtl] & {
-    padding: {
-      left: var(--sp-extra-tight);
-      right: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
-    }
-  }
-
-  .channel-intro__content {
-    margin-top: var(--sp-extra-loose);
-    max-width: 640px;
-  }
-  &__name {
-    color: var(--tc-surface-high);
-  }
-  &__desc {
-    color: var(--tc-surface-normal);
-    margin: var(--sp-tight) 0 var(--sp-extra-tight);
-    & a {
-      word-break: break-all;
-    }
-  }
-  &__time {
-    color: var(--tc-surface-low);
-  }
-}
\ No newline at end of file
diff --git a/src/app/molecules/channel-selector/ChannelSelector.jsx b/src/app/molecules/channel-selector/ChannelSelector.jsx
deleted file mode 100644 (file)
index 076b5fe..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import './ChannelSelector.scss';
-
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import Avatar from '../../atoms/avatar/Avatar';
-import NotificationBadge from '../../atoms/badge/NotificationBadge';
-import { blurOnBubbling } from '../../atoms/button/script';
-
-function ChannelSelectorWrapper({
-  isSelected, onClick, content, options,
-}) {
-  return (
-    <div className={`channel-selector${isSelected ? ' channel-selector--selected' : ''}`}>
-      <button
-        className="channel-selector__content"
-        type="button"
-        onClick={onClick}
-        onMouseUp={(e) => blurOnBubbling(e, '.channel-selector')}
-      >
-        {content}
-      </button>
-      <div className="channel-selector__options">{options}</div>
-    </div>
-  );
-}
-ChannelSelectorWrapper.defaultProps = {
-  options: null,
-};
-ChannelSelectorWrapper.propTypes = {
-  isSelected: PropTypes.bool.isRequired,
-  onClick: PropTypes.func.isRequired,
-  content: PropTypes.node.isRequired,
-  options: PropTypes.node,
-};
-
-function ChannelSelector({
-  name, roomId, imageSrc, iconSrc,
-  isSelected, isUnread, notificationCount, isAlert,
-  options, onClick,
-}) {
-  return (
-    <ChannelSelectorWrapper
-      isSelected={isSelected}
-      content={(
-        <>
-          <Avatar
-            text={name.slice(0, 1)}
-            bgColor={colorMXID(roomId)}
-            imageSrc={imageSrc}
-            iconSrc={iconSrc}
-            size="extra-small"
-          />
-          <Text variant="b1">{name}</Text>
-          { isUnread && (
-            <NotificationBadge
-              alert={isAlert}
-              content={notificationCount !== 0 ? notificationCount : null}
-            />
-          )}
-        </>
-      )}
-      options={options}
-      onClick={onClick}
-    />
-  );
-}
-ChannelSelector.defaultProps = {
-  imageSrc: null,
-  iconSrc: null,
-  options: null,
-};
-ChannelSelector.propTypes = {
-  name: PropTypes.string.isRequired,
-  roomId: PropTypes.string.isRequired,
-  imageSrc: PropTypes.string,
-  iconSrc: PropTypes.string,
-  isSelected: PropTypes.bool.isRequired,
-  isUnread: PropTypes.bool.isRequired,
-  notificationCount: PropTypes.number.isRequired,
-  isAlert: PropTypes.bool.isRequired,
-  options: PropTypes.node,
-  onClick: PropTypes.func.isRequired,
-};
-
-export default ChannelSelector;
diff --git a/src/app/molecules/channel-selector/ChannelSelector.scss b/src/app/molecules/channel-selector/ChannelSelector.scss
deleted file mode 100644 (file)
index 31385f3..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-.channel-selector-flex {
-  display: flex;
-  align-items: center;
-}
-.channel-selector-flexItem {
-  flex: 1;
-  min-width: 0;
-  min-height: 0;
-}
-
-.channel-selector {
-  @extend .channel-selector-flex;
-
-  border: 1px solid transparent;
-  border-radius: var(--bo-radius);
-  cursor: pointer;
-  
-  &--selected {
-    background-color: var(--bg-surface);
-    border-color: var(--bg-surface-border);
-
-    & .channel-selector__options {
-      display: flex;
-    }
-  }
-
-  @media (hover: hover) {
-    &:hover {
-      background-color: var(--bg-surface-hover);
-      & .channel-selector__options {
-        display: flex;
-      }
-    }
-  }
-  &:focus {
-    outline: none;
-    background-color: var(--bg-surface-hover);
-  }
-  &:active {
-    background-color: var(--bg-surface-active);
-  }
-  &--selected:hover,
-  &--selected:focus,
-  &--selected:active {
-    background-color: var(--bg-surface);
-  }
-}
-
-.channel-selector__content {
-  @extend .channel-selector-flexItem;
-  @extend .channel-selector-flex;
-  padding: 0 var(--sp-extra-tight);
-  min-height: 40px;
-  cursor: inherit;
-
-  & > .avatar-container .avatar__bordered {
-    box-shadow: none;
-  }
-
-  & > .text {
-    @extend .channel-selector-flexItem;
-    margin: 0 var(--sp-extra-tight);
-
-    color: var(--tc-surface-normal);
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-  }
-}
-.channel-selector__options {
-  @extend .channel-selector-flex;
-  display: none;
-  margin-right: var(--sp-ultra-tight);
-
-  [dir=rtl] & {
-    margin-right: 0;
-    margin-left: var(--sp-ultra-tight);
-  }
-
-  &:empty {
-    margin: 0 !important;
-  }
-
-  & .ic-btn-surface {
-    padding: 6px;
-    border-radius: calc(var(--bo-radius) / 2);
-  }
-}
\ No newline at end of file
diff --git a/src/app/molecules/channel-tile/ChannelTile.jsx b/src/app/molecules/channel-tile/ChannelTile.jsx
deleted file mode 100644 (file)
index dfb384d..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import './ChannelTile.scss';
-
-import Linkify from 'linkifyjs/react';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import Avatar from '../../atoms/avatar/Avatar';
-
-function linkifyContent(content) {
-  return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
-}
-
-function ChannelTile({
-  avatarSrc, name, id,
-  inviterName, memberCount, desc, options,
-}) {
-  return (
-    <div className="channel-tile">
-      <div className="channel-tile__avatar">
-        <Avatar
-          imageSrc={avatarSrc}
-          bgColor={colorMXID(id)}
-          text={name.slice(0, 1)}
-        />
-      </div>
-      <div className="channel-tile__content">
-        <Text variant="s1">{name}</Text>
-        <Text variant="b3">
-          {
-            inviterName !== null
-              ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` â€¢ ${memberCount} members`}`
-              : id + (memberCount === null ? '' : ` â€¢ ${memberCount} members`)
-          }
-        </Text>
-        {
-          desc !== null && (typeof desc === 'string')
-            ? <Text className="channel-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text>
-            : desc
-        }
-      </div>
-      { options !== null && (
-        <div className="channel-tile__options">
-          {options}
-        </div>
-      )}
-    </div>
-  );
-}
-
-ChannelTile.defaultProps = {
-  avatarSrc: null,
-  inviterName: null,
-  options: null,
-  desc: null,
-  memberCount: null,
-};
-ChannelTile.propTypes = {
-  avatarSrc: PropTypes.string,
-  name: PropTypes.string.isRequired,
-  id: PropTypes.string.isRequired,
-  inviterName: PropTypes.string,
-  memberCount: PropTypes.oneOfType([
-    PropTypes.string,
-    PropTypes.number,
-  ]),
-  desc: PropTypes.node,
-  options: PropTypes.node,
-};
-
-export default ChannelTile;
diff --git a/src/app/molecules/channel-tile/ChannelTile.scss b/src/app/molecules/channel-tile/ChannelTile.scss
deleted file mode 100644 (file)
index ce20195..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-.channel-tile {
-  display: flex;
-
-  &__content {
-    flex: 1;
-    min-width: 0;
-
-    margin: 0 var(--sp-normal);
-
-    &__desc {
-      white-space: pre-wrap;
-      & a {
-        white-space: wrap;
-      }
-    }
-
-    & .text:not(:first-child) {
-      margin-top: var(--sp-ultra-tight);
-    }
-  }
-}
\ No newline at end of file
diff --git a/src/app/molecules/room-intro/RoomIntro.jsx b/src/app/molecules/room-intro/RoomIntro.jsx
new file mode 100644 (file)
index 0000000..df5618d
--- /dev/null
@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import './RoomIntro.scss';
+
+import Linkify from 'linkifyjs/react';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import Avatar from '../../atoms/avatar/Avatar';
+
+function linkifyContent(content) {
+  return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
+}
+
+function RoomIntro({
+  roomId, avatarSrc, name, heading, desc, time,
+}) {
+  return (
+    <div className="room-intro">
+      <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={colorMXID(roomId)} size="large" />
+      <div className="room-intro__content">
+        <Text className="room-intro__name" variant="h1">{heading}</Text>
+        <Text className="room-intro__desc" variant="b1">{linkifyContent(desc)}</Text>
+        { time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
+      </div>
+    </div>
+  );
+}
+
+RoomIntro.defaultProps = {
+  avatarSrc: false,
+  time: null,
+};
+
+RoomIntro.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  avatarSrc: PropTypes.oneOfType([
+    PropTypes.string,
+    PropTypes.bool,
+  ]),
+  name: PropTypes.string.isRequired,
+  heading: PropTypes.string.isRequired,
+  desc: PropTypes.string.isRequired,
+  time: PropTypes.string,
+};
+
+export default RoomIntro;
diff --git a/src/app/molecules/room-intro/RoomIntro.scss b/src/app/molecules/room-intro/RoomIntro.scss
new file mode 100644 (file)
index 0000000..8e923f3
--- /dev/null
@@ -0,0 +1,31 @@
+.room-intro {
+  margin-top: calc(2 * var(--sp-extra-loose));
+  margin-bottom: var(--sp-extra-loose);
+  padding-left: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
+  padding-right: var(--sp-extra-tight);
+
+  [dir=rtl] & {
+    padding: {
+      left: var(--sp-extra-tight);
+      right: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
+    }
+  }
+
+  .room-intro__content {
+    margin-top: var(--sp-extra-loose);
+    max-width: 640px;
+  }
+  &__name {
+    color: var(--tc-surface-high);
+  }
+  &__desc {
+    color: var(--tc-surface-normal);
+    margin: var(--sp-tight) 0 var(--sp-extra-tight);
+    & a {
+      word-break: break-all;
+    }
+  }
+  &__time {
+    color: var(--tc-surface-low);
+  }
+}
\ No newline at end of file
diff --git a/src/app/molecules/room-selector/RoomSelector.jsx b/src/app/molecules/room-selector/RoomSelector.jsx
new file mode 100644 (file)
index 0000000..01e2ffc
--- /dev/null
@@ -0,0 +1,88 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import './RoomSelector.scss';
+
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import Avatar from '../../atoms/avatar/Avatar';
+import NotificationBadge from '../../atoms/badge/NotificationBadge';
+import { blurOnBubbling } from '../../atoms/button/script';
+
+function RoomSelectorWrapper({
+  isSelected, onClick, content, options,
+}) {
+  return (
+    <div className={`room-selector${isSelected ? ' room-selector--selected' : ''}`}>
+      <button
+        className="room-selector__content"
+        type="button"
+        onClick={onClick}
+        onMouseUp={(e) => blurOnBubbling(e, '.room-selector')}
+      >
+        {content}
+      </button>
+      <div className="room-selector__options">{options}</div>
+    </div>
+  );
+}
+RoomSelectorWrapper.defaultProps = {
+  options: null,
+};
+RoomSelectorWrapper.propTypes = {
+  isSelected: PropTypes.bool.isRequired,
+  onClick: PropTypes.func.isRequired,
+  content: PropTypes.node.isRequired,
+  options: PropTypes.node,
+};
+
+function RoomSelector({
+  name, roomId, imageSrc, iconSrc,
+  isSelected, isUnread, notificationCount, isAlert,
+  options, onClick,
+}) {
+  return (
+    <RoomSelectorWrapper
+      isSelected={isSelected}
+      content={(
+        <>
+          <Avatar
+            text={name.slice(0, 1)}
+            bgColor={colorMXID(roomId)}
+            imageSrc={imageSrc}
+            iconSrc={iconSrc}
+            size="extra-small"
+          />
+          <Text variant="b1">{name}</Text>
+          { isUnread && (
+            <NotificationBadge
+              alert={isAlert}
+              content={notificationCount !== 0 ? notificationCount : null}
+            />
+          )}
+        </>
+      )}
+      options={options}
+      onClick={onClick}
+    />
+  );
+}
+RoomSelector.defaultProps = {
+  imageSrc: null,
+  iconSrc: null,
+  options: null,
+};
+RoomSelector.propTypes = {
+  name: PropTypes.string.isRequired,
+  roomId: PropTypes.string.isRequired,
+  imageSrc: PropTypes.string,
+  iconSrc: PropTypes.string,
+  isSelected: PropTypes.bool.isRequired,
+  isUnread: PropTypes.bool.isRequired,
+  notificationCount: PropTypes.number.isRequired,
+  isAlert: PropTypes.bool.isRequired,
+  options: PropTypes.node,
+  onClick: PropTypes.func.isRequired,
+};
+
+export default RoomSelector;
diff --git a/src/app/molecules/room-selector/RoomSelector.scss b/src/app/molecules/room-selector/RoomSelector.scss
new file mode 100644 (file)
index 0000000..61e2cbc
--- /dev/null
@@ -0,0 +1,88 @@
+.room-selector-flex {
+  display: flex;
+  align-items: center;
+}
+.room-selector-flexItem {
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+}
+
+.room-selector {
+  @extend .room-selector-flex;
+
+  border: 1px solid transparent;
+  border-radius: var(--bo-radius);
+  cursor: pointer;
+  
+  &--selected {
+    background-color: var(--bg-surface);
+    border-color: var(--bg-surface-border);
+
+    & .room-selector__options {
+      display: flex;
+    }
+  }
+
+  @media (hover: hover) {
+    &:hover {
+      background-color: var(--bg-surface-hover);
+      & .room-selector__options {
+        display: flex;
+      }
+    }
+  }
+  &:focus {
+    outline: none;
+    background-color: var(--bg-surface-hover);
+  }
+  &:active {
+    background-color: var(--bg-surface-active);
+  }
+  &--selected:hover,
+  &--selected:focus,
+  &--selected:active {
+    background-color: var(--bg-surface);
+  }
+}
+
+.room-selector__content {
+  @extend .room-selector-flexItem;
+  @extend .room-selector-flex;
+  padding: 0 var(--sp-extra-tight);
+  min-height: 40px;
+  cursor: inherit;
+
+  & > .avatar-container .avatar__bordered {
+    box-shadow: none;
+  }
+
+  & > .text {
+    @extend .room-selector-flexItem;
+    margin: 0 var(--sp-extra-tight);
+
+    color: var(--tc-surface-normal);
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+}
+.room-selector__options {
+  @extend .room-selector-flex;
+  display: none;
+  margin-right: var(--sp-ultra-tight);
+
+  [dir=rtl] & {
+    margin-right: 0;
+    margin-left: var(--sp-ultra-tight);
+  }
+
+  &:empty {
+    margin: 0 !important;
+  }
+
+  & .ic-btn-surface {
+    padding: 6px;
+    border-radius: calc(var(--bo-radius) / 2);
+  }
+}
\ No newline at end of file
diff --git a/src/app/molecules/room-tile/RoomTile.jsx b/src/app/molecules/room-tile/RoomTile.jsx
new file mode 100644 (file)
index 0000000..a9a680d
--- /dev/null
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import './RoomTile.scss';
+
+import Linkify from 'linkifyjs/react';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import Avatar from '../../atoms/avatar/Avatar';
+
+function linkifyContent(content) {
+  return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
+}
+
+function RoomTile({
+  avatarSrc, name, id,
+  inviterName, memberCount, desc, options,
+}) {
+  return (
+    <div className="room-tile">
+      <div className="room-tile__avatar">
+        <Avatar
+          imageSrc={avatarSrc}
+          bgColor={colorMXID(id)}
+          text={name.slice(0, 1)}
+        />
+      </div>
+      <div className="room-tile__content">
+        <Text variant="s1">{name}</Text>
+        <Text variant="b3">
+          {
+            inviterName !== null
+              ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` â€¢ ${memberCount} members`}`
+              : id + (memberCount === null ? '' : ` â€¢ ${memberCount} members`)
+          }
+        </Text>
+        {
+          desc !== null && (typeof desc === 'string')
+            ? <Text className="room-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text>
+            : desc
+        }
+      </div>
+      { options !== null && (
+        <div className="room-tile__options">
+          {options}
+        </div>
+      )}
+    </div>
+  );
+}
+
+RoomTile.defaultProps = {
+  avatarSrc: null,
+  inviterName: null,
+  options: null,
+  desc: null,
+  memberCount: null,
+};
+RoomTile.propTypes = {
+  avatarSrc: PropTypes.string,
+  name: PropTypes.string.isRequired,
+  id: PropTypes.string.isRequired,
+  inviterName: PropTypes.string,
+  memberCount: PropTypes.oneOfType([
+    PropTypes.string,
+    PropTypes.number,
+  ]),
+  desc: PropTypes.node,
+  options: PropTypes.node,
+};
+
+export default RoomTile;
diff --git a/src/app/molecules/room-tile/RoomTile.scss b/src/app/molecules/room-tile/RoomTile.scss
new file mode 100644 (file)
index 0000000..bbed710
--- /dev/null
@@ -0,0 +1,21 @@
+.room-tile {
+  display: flex;
+
+  &__content {
+    flex: 1;
+    min-width: 0;
+
+    margin: 0 var(--sp-normal);
+
+    &__desc {
+      white-space: pre-wrap;
+      & a {
+        white-space: wrap;
+      }
+    }
+
+    & .text:not(:first-child) {
+      margin-top: var(--sp-ultra-tight);
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/channel/Channel.jsx b/src/app/organisms/channel/Channel.jsx
deleted file mode 100644 (file)
index d980152..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import './Channel.scss';
-
-import cons from '../../../client/state/cons';
-import navigation from '../../../client/state/navigation';
-
-import Welcome from '../welcome/Welcome';
-import ChannelView from './ChannelView';
-import PeopleDrawer from './PeopleDrawer';
-
-function Channel() {
-  const [selectedRoomId, changeSelectedRoomId] = useState(null);
-  const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible);
-  useEffect(() => {
-    const handleRoomSelected = (roomId) => {
-      changeSelectedRoomId(roomId);
-    };
-    const handleDrawerToggling = (visiblity) => {
-      toggleDrawerVisiblity(visiblity);
-    };
-    navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
-    navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
-
-    return () => {
-      navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
-      navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
-    };
-  }, []);
-
-  if (selectedRoomId === null) return <Welcome />;
-
-  return (
-    <div className="channel-container">
-      <ChannelView roomId={selectedRoomId} />
-      { isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
-    </div>
-  );
-}
-
-export default Channel;
diff --git a/src/app/organisms/channel/Channel.scss b/src/app/organisms/channel/Channel.scss
deleted file mode 100644 (file)
index 1d6b6ee..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-.channel-container {
-  display: flex;
-  height: 100%;
-}
\ No newline at end of file
diff --git a/src/app/organisms/channel/ChannelView.jsx b/src/app/organisms/channel/ChannelView.jsx
deleted file mode 100644 (file)
index 07b9bf1..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelView.scss';
-
-import EventEmitter from 'events';
-
-import RoomTimeline from '../../../client/state/RoomTimeline';
-
-import ScrollView from '../../atoms/scroll/ScrollView';
-
-import ChannelViewHeader from './ChannelViewHeader';
-import ChannelViewContent from './ChannelViewContent';
-import ChannelViewFloating from './ChannelViewFloating';
-import ChannelViewInput from './ChannelViewInput';
-import ChannelViewCmdBar from './ChannelViewCmdBar';
-
-import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
-
-const viewEvent = new EventEmitter();
-
-let lastScrollTop = 0;
-let lastScrollHeight = 0;
-let isReachedBottom = true;
-let isReachedTop = false;
-function ChannelView({ roomId }) {
-  const [roomTimeline, updateRoomTimeline] = useState(null);
-  const timelineSVRef = useRef(null);
-
-  useEffect(() => {
-    roomTimeline?.removeInternalListeners();
-    updateRoomTimeline(new RoomTimeline(roomId));
-    isReachedBottom = true;
-    isReachedTop = false;
-  }, [roomId]);
-
-  const timelineScroll = {
-    reachBottom() {
-      scrollToBottom(timelineSVRef);
-    },
-    autoReachBottom() {
-      autoScrollToBottom(timelineSVRef);
-    },
-    tryRestoringScroll() {
-      const sv = timelineSVRef.current;
-      const { scrollHeight } = sv;
-
-      if (lastScrollHeight === scrollHeight) return;
-
-      if (lastScrollHeight < scrollHeight) {
-        sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
-      } else {
-        timelineScroll.reachBottom();
-      }
-    },
-    enableSmoothScroll() {
-      timelineSVRef.current.style.scrollBehavior = 'smooth';
-    },
-    disableSmoothScroll() {
-      timelineSVRef.current.style.scrollBehavior = 'auto';
-    },
-    isScrollable() {
-      const oHeight = timelineSVRef.current.offsetHeight;
-      const sHeight = timelineSVRef.current.scrollHeight;
-      if (sHeight > oHeight) return true;
-      return false;
-    },
-  };
-
-  function onTimelineScroll(e) {
-    const { scrollTop, scrollHeight, offsetHeight } = e.target;
-    const scrollBottom = scrollTop + offsetHeight;
-    lastScrollTop = scrollTop;
-    lastScrollHeight = scrollHeight;
-
-    const PLACEHOLDER_HEIGHT = 96;
-    const PLACEHOLDER_COUNT = 3;
-
-    const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT;
-    const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2);
-
-    if (!isReachedBottom && isAtBottom(timelineSVRef)) {
-      isReachedBottom = true;
-      viewEvent.emit('toggle-reached-bottom', true);
-    }
-    if (isReachedBottom && !isAtBottom(timelineSVRef)) {
-      isReachedBottom = false;
-      viewEvent.emit('toggle-reached-bottom', false);
-    }
-    // TOP of timeline
-    if (scrollTop < topPagKeyPoint && isReachedTop === false) {
-      isReachedTop = true;
-      viewEvent.emit('reached-top');
-      return;
-    }
-    isReachedTop = false;
-
-    // BOTTOM of timeline
-    if (scrollBottom > bottomPagKeyPoint) {
-      // TODO:
-    }
-  }
-
-  return (
-    <div className="channel-view">
-      <ChannelViewHeader roomId={roomId} />
-      <div className="channel-view__content-wrapper">
-        <div className="channel-view__scrollable">
-          <ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide>
-            {roomTimeline !== null && (
-              <ChannelViewContent
-                roomId={roomId}
-                roomTimeline={roomTimeline}
-                timelineScroll={timelineScroll}
-                viewEvent={viewEvent}
-              />
-            )}
-          </ScrollView>
-          {roomTimeline !== null && (
-            <ChannelViewFloating
-              roomId={roomId}
-              roomTimeline={roomTimeline}
-              timelineScroll={timelineScroll}
-              viewEvent={viewEvent}
-            />
-          )}
-        </div>
-        {roomTimeline !== null && (
-          <div className="channel-view__sticky">
-            <ChannelViewInput
-              roomId={roomId}
-              roomTimeline={roomTimeline}
-              timelineScroll={timelineScroll}
-              viewEvent={viewEvent}
-            />
-            <ChannelViewCmdBar
-              roomId={roomId}
-              roomTimeline={roomTimeline}
-              viewEvent={viewEvent}
-            />
-          </div>
-        )}
-      </div>
-    </div>
-  );
-}
-ChannelView.propTypes = {
-  roomId: PropTypes.string.isRequired,
-};
-
-export default ChannelView;
diff --git a/src/app/organisms/channel/ChannelView.scss b/src/app/organisms/channel/ChannelView.scss
deleted file mode 100644 (file)
index a50a9ae..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-.channel-view-flexBox {
-  display: flex;
-  flex-direction: column;
-}
-.channel-view-flexItem {
-  flex: 1;
-  min-height: 0;
-  min-width: 0;
-}
-
-.channel-view {
-  @extend .channel-view-flexItem;
-  @extend .channel-view-flexBox;
-
-  &__content-wrapper {
-    @extend .channel-view-flexItem;
-    @extend .channel-view-flexBox;
-  }
-
-  &__scrollable {
-    @extend .channel-view-flexItem;
-    position: relative;
-  }
-  
-  &__sticky {
-    min-height: 85px;
-    position: relative;
-    background: var(--bg-surface);
-    border-top: 1px solid var(--bg-surface-border);
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/channel/ChannelViewCmdBar.jsx b/src/app/organisms/channel/ChannelViewCmdBar.jsx
deleted file mode 100644 (file)
index 40d3ff5..0000000
+++ /dev/null
@@ -1,475 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewCmdBar.scss';
-import parse from 'html-react-parser';
-import twemoji from 'twemoji';
-
-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,
-  openReadReceipts,
-} from '../../../client/action/navigation';
-import { emojis } from '../emoji-board/emoji';
-import AsyncSearch from '../../../util/AsyncSearch';
-
-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';
-
-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>
-          <Text variant="b2">@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;
-
-  function handleOnMessageSent() {
-    setFollowingMembers([]);
-  }
-
-  function updateFollowingMembers() {
-    const room = mx.getRoom(roomId);
-    const { timeline } = room;
-    const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
-    const myUserId = mx.getUserId();
-    setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
-  }
-
-  useEffect(() => updateFollowingMembers(), [roomId]);
-
-  useEffect(() => {
-    roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
-    viewEvent.on('message_sent', handleOnMessageSent);
-    return () => {
-      roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
-      viewEvent.removeListener('message_sent', handleOnMessageSent);
-    };
-  }, [roomTimeline]);
-
-  const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1];
-  return followingMembers.length !== 0 && (
-    <TimelineChange
-      variant="follow"
-      content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')}
-      time=""
-      onClick={() => openReadReceipts(roomId, lastMEvent.getId())}
-    />
-  );
-}
-
-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.'),
-    '@': () => genMessage('Name autofill command mode activated. ', 'Type name for suggestions.'),
-  };
-  return cmd[prefix]?.();
-}
-
-function CmdItem({ onClick, children }) {
-  return (
-    <button className="cmd-item" onClick={onClick} type="button">
-      {children}
-    </button>
-  );
-}
-CmdItem.propTypes = {
-  onClick: PropTypes.func.isRequired,
-  children: PropTypes.node.isRequired,
-};
-
-function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
-  function getGenCmdSuggestions(cmdPrefix, cmds) {
-    const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?';
-    return cmds.map((cmd) => (
-      <CmdItem
-        key={cmd.name}
-        onClick={() => {
-          fireCmd({
-            prefix: cmdPrefix,
-            option,
-            result: cmd,
-          });
-        }}
-      >
-        <Text variant="b2">{`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`}</Text>
-      </CmdItem>
-    ));
-  }
-
-  function getRoomsSuggestion(cmdPrefix, rooms) {
-    return rooms.map((room) => (
-      <CmdItem
-        key={room.roomId}
-        onClick={() => {
-          fireCmd({
-            prefix: cmdPrefix,
-            result: room,
-          });
-        }}
-      >
-        <Text variant="b2">{room.name}</Text>
-      </CmdItem>
-    ));
-  }
-
-  function getEmojiSuggestion(emPrefix, emos) {
-    return emos.map((emoji) => (
-      <CmdItem
-        key={emoji.hexcode}
-        onClick={() => fireCmd({
-          prefix: emPrefix,
-          result: emoji,
-        })}
-      >
-        {
-          parse(twemoji.parse(
-            emoji.unicode,
-            {
-              attributes: () => ({
-                unicode: emoji.unicode,
-                shortcodes: emoji.shortcodes?.toString(),
-              }),
-            },
-          ))
-        }
-        <Text variant="b2">{`:${emoji.shortcode}:`}</Text>
-      </CmdItem>
-    ));
-  }
-
-  function getNameSuggestion(namePrefix, members) {
-    return members.map((member) => (
-      <CmdItem
-        key={member.userId}
-        onClick={() => {
-          fireCmd({
-            prefix: namePrefix,
-            result: member,
-          });
-        }}
-      >
-        <Text variant="b2">{member.name}</Text>
-      </CmdItem>
-    ));
-  }
-
-  const cmd = {
-    '/': (cmds) => getGenCmdSuggestions(prefix, cmds),
-    '>*': (spaces) => getRoomsSuggestion(prefix, spaces),
-    '>#': (channels) => getRoomsSuggestion(prefix, channels),
-    '>@': (peoples) => getRoomsSuggestion(prefix, peoples),
-    ':': (emos) => getEmojiSuggestion(prefix, emos),
-    '@': (members) => getNameSuggestion(prefix, members),
-  };
-  return cmd[prefix]?.(suggestions);
-}
-
-const asyncSearch = new AsyncSearch();
-let cmdPrefix;
-let cmdOption;
-function ChannelViewCmdBar({ roomId, roomTimeline, viewEvent }) {
-  const [cmd, setCmd] = useState(null);
-
-  function displaySuggestions(suggestions) {
-    if (suggestions.length === 0) {
-      setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
-      viewEvent.emit('cmd_error');
-      return;
-    }
-    setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
-  }
-
-  function processCmd(prefix, slug) {
-    let searchTerm = slug;
-    cmdOption = undefined;
-    cmdPrefix = prefix;
-    if (prefix === '/') {
-      const cmdSlugParts = slug.split('/');
-      [searchTerm, cmdOption] = cmdSlugParts;
-    }
-    if (prefix === ':') {
-      if (searchTerm.length <= 3) {
-        if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
-        else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
-        else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
-        else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
-        else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
-        else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
-        else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
-        else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
-        else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
-        else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
-        else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
-        else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
-      }
-    }
-
-    asyncSearch.search(searchTerm);
-  }
-  function activateCmd(prefix) {
-    setCmd({ prefix });
-    cmdPrefix = prefix;
-
-    const { roomList, matrixClient } = initMatrix;
-    function getRooms(roomIds) {
-      return roomIds.map((rId) => {
-        const room = matrixClient.getRoom(rId);
-        return {
-          name: room.name,
-          roomId: room.roomId,
-        };
-      });
-    }
-    const setupSearch = {
-      '/': () => asyncSearch.setup(commands, { keys: ['name'], isContain: true }),
-      '>*': () => asyncSearch.setup(getRooms([...roomList.spaces]), { keys: ['name'], limit: 20 }),
-      '>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }),
-      '>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { keys: ['name'], limit: 20 }),
-      ':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 20 }),
-      '@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({
-        name: member.name,
-        userId: member.userId.slice(1),
-      })), { keys: ['name', 'userId'], limit: 20 }),
-    };
-    setupSearch[prefix]?.();
-  }
-  function deactivateCmd() {
-    setCmd(null);
-    cmdOption = undefined;
-    cmdPrefix = undefined;
-  }
-  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,
-      });
-    }
-    if (myCmd.prefix === '@') {
-      viewEvent.emit('cmd_fired', {
-        replace: myCmd.result.name,
-      });
-    }
-    deactivateCmd();
-  }
-  function executeCmd() {
-    if (cmd.suggestions.length === 0) return;
-    fireCmd({
-      prefix: cmd.prefix,
-      option: cmd.option,
-      result: cmd.suggestions[0],
-    });
-  }
-
-  function listenKeyboard(event) {
-    const { activeElement } = document;
-    const lastCmdItem = document.activeElement.parentNode.lastElementChild;
-    if (event.keyCode === 27) {
-      if (activeElement.className !== 'cmd-item') return;
-      viewEvent.emit('focus_msg_input');
-    }
-    if (event.keyCode === 9) {
-      if (lastCmdItem.className !== 'cmd-item') return;
-      if (lastCmdItem !== activeElement) return;
-      if (event.shiftKey) return;
-      viewEvent.emit('focus_msg_input');
-      event.preventDefault();
-    }
-  }
-
-  useEffect(() => {
-    viewEvent.on('cmd_activate', activateCmd);
-    viewEvent.on('cmd_deactivate', deactivateCmd);
-    return () => {
-      deactivateCmd();
-      viewEvent.removeListener('cmd_activate', activateCmd);
-      viewEvent.removeListener('cmd_deactivate', deactivateCmd);
-    };
-  }, [roomId]);
-
-  useEffect(() => {
-    if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
-    viewEvent.on('cmd_process', processCmd);
-    viewEvent.on('cmd_exe', executeCmd);
-    asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
-    return () => {
-      if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
-
-      viewEvent.removeListener('cmd_process', processCmd);
-      viewEvent.removeListener('cmd_exe', executeCmd);
-      asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
-    };
-  }, [cmd]);
-
-  if (typeof cmd?.error === 'string') {
-    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.suggestions === 'undefined' && <div className="cmd-bar__info-indicator" /> }
-        {cmd !== null && typeof cmd.suggestions !== 'undefined' && <Text variant="b3">TAB</Text>}
-      </div>
-      <div className="cmd-bar__content">
-        {cmd === null && (
-          <FollowingMembers
-            roomId={roomId}
-            roomTimeline={roomTimeline}
-            viewEvent={viewEvent}
-          />
-        )}
-        {cmd !== null && typeof cmd.suggestions === 'undefined' && <Text className="cmd-bar__content-help" variant="b2">{getCmdActivationMessage(cmd.prefix)}</Text>}
-        {cmd !== null && typeof cmd.suggestions !== 'undefined' && (
-          <ScrollView horizontal vertical={false} invisible>
-            <div className="cmd-bar__content__suggestions">{getCmdSuggestions(cmd, fireCmd)}</div>
-          </ScrollView>
-        )}
-      </div>
-      <div className="cmd-bar__more">
-        {cmd !== null && cmd.prefix === '/' && <ViewCmd />}
-      </div>
-    </div>
-  );
-}
-ChannelViewCmdBar.propTypes = {
-  roomId: PropTypes.string.isRequired,
-  roomTimeline: PropTypes.shape({}).isRequired,
-  viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewCmdBar;
diff --git a/src/app/organisms/channel/ChannelViewCmdBar.scss b/src/app/organisms/channel/ChannelViewCmdBar.scss
deleted file mode 100644 (file)
index dc8a981..0000000
+++ /dev/null
@@ -1,144 +0,0 @@
-.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 {
-        @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;
-
-  & .emoji {
-    width: 20px;
-    height: 20px;
-    margin-right: var(--sp-ultra-tight);
-  }
-
-  &:hover {
-    background-color: var(--bg-caution-hover);
-  }
-  &:focus {
-    background-color: var(--bg-caution-active);
-    box-shadow: var(--cmd-item-bar);
-    border-bottom: 2px solid transparent;
-    outline: none;
-  }
-  
-  [dir=rtl] & {
-    margin-right: 0;
-    margin-left: var(--sp-extra-tight);
-    & .emoji {
-      margin-right: 0;
-      margin-left: var(--sp-ultra-tight);
-    }
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/channel/ChannelViewContent.jsx b/src/app/organisms/channel/ChannelViewContent.jsx
deleted file mode 100644 (file)
index 063718b..0000000
+++ /dev/null
@@ -1,581 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect, useLayoutEffect } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewContent.scss';
-
-import dateFormat from 'dateformat';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
-import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil';
-import colorMXID from '../../../util/colorMXID';
-import { diffMinutes, isNotInSameDay } from '../../../util/common';
-import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation';
-
-import Divider from '../../atoms/divider/Divider';
-import Avatar from '../../atoms/avatar/Avatar';
-import IconButton from '../../atoms/button/IconButton';
-import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
-import {
-  Message,
-  MessageHeader,
-  MessageReply,
-  MessageContent,
-  MessageEdit,
-  MessageReactionGroup,
-  MessageReaction,
-  MessageOptions,
-  PlaceholderMessage,
-} from '../../molecules/message/Message';
-import * as Media from '../../molecules/media/Media';
-import ChannelIntro from '../../molecules/channel-intro/ChannelIntro';
-import TimelineChange from '../../molecules/message/TimelineChange';
-
-import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
-import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
-import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
-import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
-import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
-import BinIC from '../../../../public/res/ic/outlined/bin.svg';
-
-import { parseReply, parseTimelineChange } from './common';
-
-const MAX_MSG_DIFF_MINUTES = 5;
-
-function genPlaceholders() {
-  return (
-    <>
-      <PlaceholderMessage key="placeholder-1" />
-      <PlaceholderMessage key="placeholder-2" />
-      <PlaceholderMessage key="placeholder-3" />
-    </>
-  );
-}
-
-function isMedia(mE) {
-  return (
-    mE.getContent()?.msgtype === 'm.file'
-    || mE.getContent()?.msgtype === 'm.image'
-    || mE.getContent()?.msgtype === 'm.audio'
-    || mE.getContent()?.msgtype === 'm.video'
-    || mE.getType() === 'm.sticker'
-  );
-}
-
-function genMediaContent(mE) {
-  const mx = initMatrix.matrixClient;
-  const mContent = mE.getContent();
-  if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
-
-  let mediaMXC = mContent?.url;
-  const isEncryptedFile = typeof mediaMXC === 'undefined';
-  if (isEncryptedFile) mediaMXC = mContent?.file?.url;
-
-  let thumbnailMXC = mContent?.info?.thumbnail_url;
-
-  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';
-
-  switch (msgType) {
-    case 'm.file':
-      return (
-        <Media.File
-          name={mContent.body}
-          link={mx.mxcUrlToHttp(mediaMXC)}
-          type={mContent.info?.mimetype}
-          file={mContent.file || null}
-        />
-      );
-    case 'm.image':
-      return (
-        <Media.Image
-          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
-          name={mContent.body}
-          link={mx.mxcUrlToHttp(mediaMXC)}
-          type={mContent.info?.mimetype}
-          file={mContent.file || null}
-        />
-      );
-    case 'm.video':
-      if (typeof thumbnailMXC === 'undefined') {
-        thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
-      }
-      return (
-        <Media.Video
-          name={mContent.body}
-          link={mx.mxcUrlToHttp(mediaMXC)}
-          thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
-          thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
-          thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
-          width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
-          height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
-          file={isEncryptedFile ? mContent.file : null}
-          type={mContent.info?.mimetype}
-        />
-      );
-    default:
-      return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
-  }
-}
-
-function genChannelIntro(mEvent, roomTimeline) {
-  const mx = initMatrix.matrixClient;
-  const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
-  const isDM = initMatrix.roomList.directs.has(roomTimeline.roomId);
-  let avatarSrc = roomTimeline.room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
-  avatarSrc = isDM ? roomTimeline.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
-  return (
-    <ChannelIntro
-      key={mEvent ? mEvent.getId() : 'channel-intro'}
-      roomId={roomTimeline.roomId}
-      avatarSrc={avatarSrc}
-      name={roomTimeline.room.name}
-      heading={`Welcome to ${roomTimeline.room.name}`}
-      desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
-      time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
-    />
-  );
-}
-
-function getMyEmojiEventId(emojiKey, eventId, roomTimeline) {
-  const mx = initMatrix.matrixClient;
-  const rEvents = roomTimeline.reactionTimeline.get(eventId);
-  let rEventId = null;
-  rEvents?.find((rE) => {
-    if (rE.getRelation() === null) return false;
-    if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
-      rEventId = rE.getId();
-      return true;
-    }
-    return false;
-  });
-  return rEventId;
-}
-
-function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
-  const myAlreadyReactEventId = getMyEmojiEventId(emojiKey, eventId, roomTimeline);
-  if (typeof myAlreadyReactEventId === 'string') {
-    if (myAlreadyReactEventId.indexOf('~') === 0) return;
-    redactEvent(roomId, myAlreadyReactEventId);
-    return;
-  }
-  sendReaction(roomId, eventId, emojiKey);
-}
-
-function pickEmoji(e, roomId, eventId, roomTimeline) {
-  const boxInfo = e.target.getBoundingClientRect();
-  openEmojiBoard({
-    x: boxInfo.x,
-    y: boxInfo.y,
-    detail: e.detail,
-  }, (emoji) => {
-    toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
-    e.target.click();
-  });
-}
-
-let wasAtBottom = true;
-function ChannelViewContent({
-  roomId, roomTimeline, timelineScroll, viewEvent,
-}) {
-  const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
-  const [onStateUpdate, updateState] = useState(null);
-  const [onPagination, setOnPagination] = useState(null);
-  const [editEvent, setEditEvent] = useState(null);
-  const mx = initMatrix.matrixClient;
-
-  function autoLoadTimeline() {
-    if (timelineScroll.isScrollable() === true) return;
-    roomTimeline.paginateBack();
-  }
-  function trySendingReadReceipt() {
-    const { room, timeline } = roomTimeline;
-    if (doesRoomHaveUnread(room) && timeline.length !== 0) {
-      mx.sendReadReceipt(timeline[timeline.length - 1]);
-    }
-  }
-
-  function onReachedTop() {
-    if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
-    roomTimeline.paginateBack();
-  }
-  function toggleOnReachedBottom(isBottom) {
-    wasAtBottom = isBottom;
-    if (!isBottom) return;
-    trySendingReadReceipt();
-  }
-
-  const updatePAG = (canPagMore) => {
-    if (!canPagMore) {
-      setIsReachedTimelineEnd(true);
-    } else {
-      setOnPagination({});
-      autoLoadTimeline();
-    }
-  };
-  // force update RoomTimeline on cons.events.roomTimeline.EVENT
-  const updateRT = () => {
-    if (wasAtBottom) {
-      trySendingReadReceipt();
-    }
-    updateState({});
-  };
-
-  useEffect(() => {
-    setIsReachedTimelineEnd(false);
-    wasAtBottom = true;
-  }, [roomId]);
-  useEffect(() => trySendingReadReceipt(), [roomTimeline]);
-
-  // init room setup completed.
-  // listen for future. setup stateUpdate listener.
-  useEffect(() => {
-    roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
-    roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
-    viewEvent.on('reached-top', onReachedTop);
-    viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom);
-
-    return () => {
-      roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
-      roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
-      viewEvent.removeListener('reached-top', onReachedTop);
-      viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom);
-    };
-  }, [roomTimeline, isReachedTimelineEnd, onPagination]);
-
-  useLayoutEffect(() => {
-    timelineScroll.reachBottom();
-    autoLoadTimeline();
-  }, [roomTimeline]);
-
-  useLayoutEffect(() => {
-    if (onPagination === null) return;
-    timelineScroll.tryRestoringScroll();
-  }, [onPagination]);
-
-  useEffect(() => {
-    if (onStateUpdate === null) return;
-    if (wasAtBottom) timelineScroll.reachBottom();
-  }, [onStateUpdate]);
-
-  let prevMEvent = null;
-  function genMessage(mEvent) {
-    const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel;
-    const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
-
-    const isContentOnly = (
-      prevMEvent !== null
-      && prevMEvent.getType() !== 'm.room.member'
-      && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
-      && prevMEvent.getSender() === mEvent.getSender()
-    );
-
-    let content = mEvent.getContent().body;
-    if (typeof content === 'undefined') return null;
-    let reply = null;
-    let reactions = null;
-    let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html';
-    const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
-    const isEdited = roomTimeline.editedTimeline.has(mEvent.getId());
-    const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId());
-
-    if (isReply) {
-      const parsedContent = parseReply(content);
-      if (parsedContent !== null) {
-        const c = roomTimeline.room.currentState;
-        const displayNameToUserIds = c.getUserIdsWithDisplayName(parsedContent.displayName);
-        const ID = parsedContent.userId || displayNameToUserIds[0];
-        reply = {
-          color: colorMXID(ID || parsedContent.displayName),
-          to: parsedContent.displayName || getUsername(parsedContent.userId),
-          content: parsedContent.replyContent,
-        };
-        content = parsedContent.content;
-      }
-    }
-
-    if (isEdited) {
-      const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
-      const latestEdited = editedList[editedList.length - 1];
-      if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null;
-      const latestEditBody = latestEdited.getContent()['m.new_content'].body;
-      const parsedEditedContent = parseReply(latestEditBody);
-      isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html';
-      if (parsedEditedContent === null) {
-        content = latestEditBody;
-      } else {
-        content = parsedEditedContent.content;
-      }
-    }
-
-    if (haveReactions) {
-      reactions = [];
-      roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => {
-        if (rEvent.getRelation() === null) return;
-        function alreadyHaveThisReaction(rE) {
-          for (let i = 0; i < reactions.length; i += 1) {
-            if (reactions[i].key === rE.getRelation().key) return true;
-          }
-          return false;
-        }
-        if (alreadyHaveThisReaction(rEvent)) {
-          for (let i = 0; i < reactions.length; i += 1) {
-            if (reactions[i].key === rEvent.getRelation().key) {
-              reactions[i].users.push(rEvent.getSender());
-              if (reactions[i].isActive !== true) {
-                const myUserId = initMatrix.matrixClient.getUserId();
-                reactions[i].isActive = rEvent.getSender() === myUserId;
-                if (reactions[i].isActive) reactions[i].id = rEvent.getId();
-              }
-              break;
-            }
-          }
-        } else {
-          reactions.push({
-            id: rEvent.getId(),
-            key: rEvent.getRelation().key,
-            users: [rEvent.getSender()],
-            isActive: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
-          });
-        }
-      });
-    }
-
-    const senderMXIDColor = colorMXID(mEvent.sender.userId);
-    const userAvatar = isContentOnly ? null : (
-      <Avatar
-        imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
-        text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
-        bgColor={senderMXIDColor}
-        size="small"
-      />
-    );
-    const userHeader = isContentOnly ? null : (
-      <MessageHeader
-        userId={mEvent.sender.userId}
-        name={getUsernameOfRoomMember(mEvent.sender)}
-        color={senderMXIDColor}
-        time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
-      />
-    );
-    const userReply = reply === null ? null : (
-      <MessageReply
-        name={reply.to}
-        color={reply.color}
-        content={reply.content}
-      />
-    );
-    const userContent = (
-      <MessageContent
-        isMarkdown={isMarkdown}
-        content={isMedia(mEvent) ? genMediaContent(mEvent) : content}
-        isEdited={isEdited}
-      />
-    );
-    const userReactions = reactions === null ? null : (
-      <MessageReactionGroup>
-        {
-          reactions.map((reaction) => (
-            <MessageReaction
-              key={reaction.id}
-              reaction={reaction.key}
-              users={reaction.users}
-              isActive={reaction.isActive}
-              onClick={() => {
-                toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline);
-              }}
-            />
-          ))
-        }
-        <IconButton
-          onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
-          src={EmojiAddIC}
-          size="extra-small"
-          tooltip="Add reaction"
-        />
-      </MessageReactionGroup>
-    );
-    const userOptions = (
-      <MessageOptions>
-        <IconButton
-          onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
-          src={EmojiAddIC}
-          size="extra-small"
-          tooltip="Add reaction"
-        />
-        <IconButton
-          onClick={() => {
-            viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
-          }}
-          src={ReplyArrowIC}
-          size="extra-small"
-          tooltip="Reply"
-        />
-        {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
-          <IconButton
-            onClick={() => setEditEvent(mEvent)}
-            src={PencilIC}
-            size="extra-small"
-            tooltip="Edit"
-          />
-        )}
-        <ContextMenu
-          content={() => (
-            <>
-              <MenuHeader>Options</MenuHeader>
-              <MenuItem
-                iconSrc={EmojiAddIC}
-                onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
-              >
-                Add reaction
-              </MenuItem>
-              <MenuItem
-                iconSrc={ReplyArrowIC}
-                onClick={() => {
-                  viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
-                }}
-              >
-                Reply
-              </MenuItem>
-              {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
-                <MenuItem iconSrc={PencilIC} onClick={() => setEditEvent(mEvent)}>Edit</MenuItem>
-              )}
-              <MenuItem
-                iconSrc={TickMarkIC}
-                onClick={() => openReadReceipts(roomId, mEvent.getId())}
-              >
-                Read receipts
-              </MenuItem>
-              {(canIRedact || mEvent.getSender() === mx.getUserId()) && (
-                <>
-                  <MenuBorder />
-                  <MenuItem
-                    variant="danger"
-                    iconSrc={BinIC}
-                    onClick={() => {
-                      if (window.confirm('Are you sure you want to delete this event')) {
-                        redactEvent(roomId, mEvent.getId());
-                      }
-                    }}
-                  >
-                    Delete
-                  </MenuItem>
-                </>
-              )}
-            </>
-          )}
-          render={(toggleMenu) => (
-            <IconButton
-              onClick={toggleMenu}
-              src={VerticalMenuIC}
-              size="extra-small"
-              tooltip="Options"
-            />
-          )}
-        />
-      </MessageOptions>
-    );
-
-    const isEditingEvent = editEvent?.getId() === mEvent.getId();
-    const myMessageEl = (
-      <Message
-        key={mEvent.getId()}
-        avatar={userAvatar}
-        header={userHeader}
-        reply={userReply}
-        content={editEvent !== null && isEditingEvent ? null : userContent}
-        editContent={editEvent !== null && isEditingEvent ? (
-          <MessageEdit
-            content={content}
-            onSave={(newBody) => {
-              if (newBody !== content) {
-                initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
-              }
-              setEditEvent(null);
-            }}
-            onCancel={() => setEditEvent(null)}
-          />
-        ) : null}
-        reactions={userReactions}
-        options={editEvent !== null && isEditingEvent ? null : userOptions}
-      />
-    );
-    return myMessageEl;
-  }
-
-  function renderMessage(mEvent) {
-    if (mEvent.getType() === 'm.room.create') return genChannelIntro(mEvent, roomTimeline);
-    if (
-      mEvent.getType() !== 'm.room.message'
-      && mEvent.getType() !== 'm.room.encrypted'
-      && mEvent.getType() !== 'm.room.member'
-      && mEvent.getType() !== 'm.sticker'
-    ) return false;
-    if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
-
-    // ignore if message is deleted
-    if (mEvent.isRedacted()) return false;
-
-    let divider = null;
-    if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
-      divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
-    }
-
-    if (mEvent.getType() !== 'm.room.member') {
-      const messageComp = genMessage(mEvent);
-      prevMEvent = mEvent;
-      return (
-        <React.Fragment key={`box-${mEvent.getId()}`}>
-          {divider}
-          {messageComp}
-        </React.Fragment>
-      );
-    }
-
-    prevMEvent = mEvent;
-    const timelineChange = parseTimelineChange(mEvent);
-    if (timelineChange === null) return null;
-    return (
-      <React.Fragment key={`box-${mEvent.getId()}`}>
-        {divider}
-        <TimelineChange
-          key={mEvent.getId()}
-          variant={timelineChange.variant}
-          content={timelineChange.content}
-          time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
-        />
-      </React.Fragment>
-    );
-  }
-
-  return (
-    <div className="channel-view__content">
-      <div className="timeline__wrapper">
-        { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() }
-        { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genChannelIntro(undefined, roomTimeline)}
-        { roomTimeline.timeline.map(renderMessage) }
-      </div>
-    </div>
-  );
-}
-ChannelViewContent.propTypes = {
-  roomId: PropTypes.string.isRequired,
-  roomTimeline: PropTypes.shape({}).isRequired,
-  timelineScroll: PropTypes.shape({}).isRequired,
-  viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewContent;
diff --git a/src/app/organisms/channel/ChannelViewContent.scss b/src/app/organisms/channel/ChannelViewContent.scss
deleted file mode 100644 (file)
index f270233..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-.channel-view__content {
-  min-height: 100%;
-  display: flex;
-  flex-direction: column;
-  justify-content: flex-end;
-
-  & .timeline__wrapper {
-    --typing-noti-height: 28px;
-    min-height: 0;
-    min-width: 0;
-    padding-bottom: var(--typing-noti-height);
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/channel/ChannelViewFloating.jsx b/src/app/organisms/channel/ChannelViewFloating.jsx
deleted file mode 100644 (file)
index e3e65da..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewFloating.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-
-import Text from '../../atoms/text/Text';
-import IconButton from '../../atoms/button/IconButton';
-
-import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
-
-import { getUsersActionJsx } from './common';
-
-function ChannelViewFloating({
-  roomId, roomTimeline, timelineScroll, viewEvent,
-}) {
-  const [reachedBottom, setReachedBottom] = useState(true);
-  const [typingMembers, setTypingMembers] = useState(new Set());
-  const mx = initMatrix.matrixClient;
-
-  function isSomeoneTyping(members) {
-    const m = members;
-    m.delete(mx.getUserId());
-    if (m.size === 0) return false;
-    return true;
-  }
-
-  function getTypingMessage(members) {
-    const userIds = members;
-    userIds.delete(mx.getUserId());
-    return getUsersActionJsx(roomId, [...userIds], 'typing...');
-  }
-
-  function updateTyping(members) {
-    setTypingMembers(members);
-  }
-
-  useEffect(() => {
-    setReachedBottom(true);
-    setTypingMembers(new Set());
-    viewEvent.on('toggle-reached-bottom', setReachedBottom);
-    return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
-  }, [roomId]);
-
-  useEffect(() => {
-    roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
-    return () => {
-      roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
-    };
-  }, [roomTimeline]);
-
-  return (
-    <>
-      <div className={`channel-view__typing${isSomeoneTyping(typingMembers) ? ' channel-view__typing--open' : ''}`}>
-        <div className="bouncingLoader"><div /></div>
-        <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
-      </div>
-      <div className={`channel-view__STB${reachedBottom ? '' : ' channel-view__STB--open'}`}>
-        <IconButton
-          onClick={() => {
-            timelineScroll.enableSmoothScroll();
-            timelineScroll.reachBottom();
-            timelineScroll.disableSmoothScroll();
-          }}
-          src={ChevronBottomIC}
-          tooltip="Scroll to Bottom"
-        />
-      </div>
-    </>
-  );
-}
-ChannelViewFloating.propTypes = {
-  roomId: PropTypes.string.isRequired,
-  roomTimeline: PropTypes.shape({}).isRequired,
-  timelineScroll: PropTypes.shape({
-    reachBottom: PropTypes.func,
-  }).isRequired,
-  viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewFloating;
diff --git a/src/app/organisms/channel/ChannelViewFloating.scss b/src/app/organisms/channel/ChannelViewFloating.scss
deleted file mode 100644 (file)
index 3c1593c..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-.channel-view {
-  &__typing {
-    display: flex;
-    padding: var(--sp-ultra-tight) var(--sp-normal);
-    background: var(--bg-surface);
-    transition: transform 200ms ease-in-out;
-
-    & b {
-      color: var(--tc-surface-high);
-    }
-
-    &--open {
-      transform: translateY(-99%);
-    }
-
-    & .text {
-      flex: 1;
-      min-width: 0;
-
-      overflow: hidden;
-      white-space: nowrap;
-      text-overflow: ellipsis;
-      margin: 0 var(--sp-tight);
-    }
-  }
-
-  .bouncingLoader {
-    transform: translateY(2px);
-    margin: 0 calc(var(--sp-ultra-tight) / 2);
-  }
-  .bouncingLoader > div,
-  .bouncingLoader:before,
-  .bouncingLoader:after {
-    display: inline-block;
-    width: 8px;
-    height: 8px;
-    background: var(--tc-surface-high);
-    border-radius: 50%;
-    animation: bouncing-loader 0.6s infinite alternate;
-  }
-  
-  .bouncingLoader:before,
-  .bouncingLoader:after {
-    content: "";
-  }
-  
-  .bouncingLoader > div {
-    margin: 0 4px;
-  }
-  
-  .bouncingLoader > div {
-    animation-delay: 0.2s;
-  }
-  
-  .bouncingLoader:after {
-    animation-delay: 0.4s;
-  }
-  
-  @keyframes bouncing-loader {
-    to {
-      opacity: 0.1;
-      transform: translate3d(0, -4px, 0);
-    }
-  }
-
-  &__STB {
-    position: absolute;
-    right: var(--sp-normal);
-    bottom: 0;
-    border-radius: var(--bo-radius);
-    box-shadow: var(--bs-surface-border);
-    background-color: var(--bg-surface-low);
-    transition: transform 200ms ease-in-out;
-    transform: translateY(100%) scale(0);
-    [dir=rtl] & {
-      right: unset;
-      left: var(--sp-normal);
-    }
-
-    &--open {
-      transform: translateY(-28px) scale(1);
-    }
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/channel/ChannelViewHeader.jsx b/src/app/organisms/channel/ChannelViewHeader.jsx
deleted file mode 100644 (file)
index f89b634..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import initMatrix from '../../../client/initMatrix';
-import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation';
-import * as roomActions from '../../../client/action/room';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import IconButton from '../../atoms/button/IconButton';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import Avatar from '../../atoms/avatar/Avatar';
-import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
-
-import UserIC from '../../../../public/res/ic/outlined/user.svg';
-import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
-import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
-import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
-
-function ChannelViewHeader({ roomId }) {
-  const mx = initMatrix.matrixClient;
-  const isDM = initMatrix.roomList.directs.has(roomId);
-  let avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
-  avatarSrc = isDM ? mx.getRoom(roomId).getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop') : avatarSrc;
-  const roomName = mx.getRoom(roomId).name;
-  const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
-
-  return (
-    <Header>
-      <Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomId)} size="small" />
-      <TitleWrapper>
-        <Text variant="h2">{roomName}</Text>
-        { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
-      </TitleWrapper>
-      <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
-      <ContextMenu
-        placement="bottom"
-        content={(toogleMenu) => (
-          <>
-            <MenuHeader>Options</MenuHeader>
-            {/* <MenuBorder /> */}
-            <MenuItem
-              iconSrc={AddUserIC}
-              onClick={() => {
-                openInviteUser(roomId); toogleMenu();
-              }}
-            >
-              Invite
-            </MenuItem>
-            <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId)}>Leave</MenuItem>
-          </>
-        )}
-        render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
-      />
-    </Header>
-  );
-}
-ChannelViewHeader.propTypes = {
-  roomId: PropTypes.string.isRequired,
-};
-
-export default ChannelViewHeader;
diff --git a/src/app/organisms/channel/ChannelViewInput.jsx b/src/app/organisms/channel/ChannelViewInput.jsx
deleted file mode 100644 (file)
index f335bb4..0000000
+++ /dev/null
@@ -1,413 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewInput.scss';
-
-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 { bytesToSize } from '../../../util/common';
-import { getUsername } from '../../../util/matrixUtil';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import RawIcon from '../../atoms/system-icons/RawIcon';
-import IconButton from '../../atoms/button/IconButton';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import { MessageReply } from '../../molecules/message/Message';
-
-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 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';
-import CrossIC from '../../../../public/res/ic/outlined/cross.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 [replyTo, setReplyTo] = useState(null);
-
-  const textAreaRef = useRef(null);
-  const inputBaseRef = useRef(null);
-  const uploadInputRef = useRef(null);
-  const uploadProgressRef = useRef(null);
-  const rightOptionsRef = useRef(null);
-  const escBtnRef = useRef(null);
-
-  const TYPING_TIMEOUT = 5000;
-  const mx = initMatrix.matrixClient;
-  const { roomsInput } = initMatrix;
-
-  function requestFocusInput() {
-    if (textAreaRef === null) return;
-    textAreaRef.current.focus();
-  }
-
-  useEffect(() => {
-    settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
-    viewEvent.on('focus_msg_input', requestFocusInput);
-    return () => {
-      settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
-      viewEvent.removeListener('focus_msg_input', requestFocusInput);
-    };
-  }, []);
-
-  const sendIsTyping = (isT) => {
-    mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
-    isTyping = isT;
-
-    if (isT === true) {
-      setTimeout(() => {
-        if (isTyping) sendIsTyping(false);
-      }, TYPING_TIMEOUT);
-    }
-  };
-
-  function uploadingProgress(myRoomId, { loaded, total }) {
-    if (myRoomId !== roomId) return;
-    const progressPer = Math.round((loaded * 100) / total);
-    uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
-    inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
-  }
-  function clearAttachment(myRoomId) {
-    if (roomId !== myRoomId) return;
-    setAttachment(null);
-    inputBaseRef.current.style.backgroundImage = 'unset';
-    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;
-    requestAnimationFrame(() => {
-      inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-positive)';
-      escBtnRef.current.style.display = 'block';
-    });
-    rightOptionsA11Y(false);
-    viewEvent.emit('cmd_activate', prefix);
-  }
-  function deactivateCmd() {
-    if (inputBaseRef.current !== null) {
-      requestAnimationFrame(() => {
-        inputBaseRef.current.style.boxShadow = 'var(--bs-surface-border)';
-        escBtnRef.current.style.display = 'none';
-      });
-      rightOptionsA11Y(true);
-    }
-    isCmdActivated = false;
-    cmdCursorPos = null;
-  }
-  function deactivateCmdAndEmit() {
-    deactivateCmd();
-    viewEvent.emit('cmd_deactivate');
-  }
-  function errorCmd() {
-    requestAnimationFrame(() => {
-      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();
-  }
-
-  function focusInput() {
-    if (settings.isTouchScreenDevice) return;
-    textAreaRef.current.focus();
-  }
-
-  function setUpReply(userId, eventId, content) {
-    setReplyTo({ userId, eventId, content });
-    roomsInput.setReplyTo(roomId, { userId, eventId, content });
-    focusInput();
-  }
-
-  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);
-    viewEvent.on('reply_to', setUpReply);
-    if (textAreaRef?.current !== null) {
-      isTyping = false;
-      focusInput();
-      textAreaRef.current.value = roomsInput.getMessage(roomId);
-      setAttachment(roomsInput.getAttachment(roomId));
-      setReplyTo(roomsInput.getReplyTo(roomId));
-    }
-    return () => {
-      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);
-      viewEvent.removeListener('reply_to', setUpReply);
-      if (isCmdActivated) deactivateCmd();
-      if (textAreaRef?.current === null) return;
-
-      const msg = textAreaRef.current.value;
-      inputBaseRef.current.style.backgroundImage = 'unset';
-      if (msg.trim() === '') {
-        roomsInput.setMessage(roomId, '');
-        return;
-      }
-      roomsInput.setMessage(roomId, msg);
-    };
-  }, [roomId]);
-
-  async function sendMessage() {
-    const msgBody = textAreaRef.current.value;
-    if (roomsInput.isSending(roomId)) return;
-    if (msgBody.trim() === '' && attachment === null) return;
-    sendIsTyping(false);
-
-    roomsInput.setMessage(roomId, msgBody);
-    if (attachment !== null) {
-      roomsInput.setAttachment(roomId, attachment);
-    }
-    textAreaRef.current.disabled = true;
-    textAreaRef.current.style.cursor = 'not-allowed';
-    await roomsInput.sendInput(roomId);
-    textAreaRef.current.disabled = false;
-    textAreaRef.current.style.cursor = 'unset';
-    focusInput();
-
-    textAreaRef.current.value = roomsInput.getMessage(roomId);
-    timelineScroll.reachBottom();
-    viewEvent.emit('message_sent');
-    textAreaRef.current.style.height = 'unset';
-    if (replyTo !== null) setReplyTo(null);
-  }
-
-  function processTyping(msg) {
-    const isEmptyMsg = msg === '';
-
-    if (isEmptyMsg && isTyping) {
-      sendIsTyping(false);
-      return;
-    }
-    if (!isEmptyMsg && !isTyping) {
-      sendIsTyping(true);
-    }
-  }
-
-  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) deactivateCmdAndEmit();
-      return;
-    }
-    const cmdPrefix = cmdParts[1];
-    const cmdSlug = cmdParts[2];
-
-    if (cmdPrefix === ':') {
-      // skip emoji autofill command if link is suspected.
-      const checkForLink = targetInput.slice(0, cmdParts.index);
-      if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
-        deactivateCmdAndEmit();
-        return;
-      }
-    }
-
-    cmdCursorPos = cursor;
-    if (cmdSlug === '') {
-      activateCmd(cmdPrefix);
-      return;
-    }
-    if (!isCmdActivated) activateCmd(cmdPrefix);
-    requestAnimationFrame(() => {
-      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;
-    recognizeCmd(e.target.value);
-    if (!isCmdActivated) processTyping(msg);
-  }
-
-  function handleKeyDown(e) {
-    if (e.keyCode === 13 && e.shiftKey === false) {
-      e.preventDefault();
-
-      if (isCmdActivated) {
-        viewEvent.emit('cmd_exe');
-      } else sendMessage();
-    }
-    if (e.keyCode === 27 && isCmdActivated) {
-      deactivateCmdAndEmit();
-      e.preventDefault();
-    }
-  }
-
-  function addEmoji(emoji) {
-    textAreaRef.current.value += emoji.unicode;
-  }
-
-  function handleUploadClick() {
-    if (attachment === null) uploadInputRef.current.click();
-    else {
-      roomsInput.cancelAttachment(roomId);
-    }
-  }
-  function uploadFileChange(e) {
-    const file = e.target.files.item(0);
-    setAttachment(file);
-    if (file !== null) roomsInput.setAttachment(roomId, file);
-  }
-
-  function renderInputs() {
-    return (
-      <>
-        <div className={`channel-input__option-container${attachment === null ? '' : ' channel-attachment__option'}`}>
-          <input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
-          <IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
-        </div>
-        <div ref={inputBaseRef} className="channel-input__input-container">
-          {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
-          <ScrollView autoHide>
-            <Text className="channel-input__textarea-wrapper">
-              <TextareaAutosize
-                ref={textAreaRef}
-                onChange={handleMsgTyping}
-                onResize={() => timelineScroll.autoReachBottom()}
-                onKeyDown={handleKeyDown}
-                placeholder="Send a message..."
-              />
-            </Text>
-          </ScrollView>
-          {isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
-          <button ref={escBtnRef} tabIndex="-1" onClick={deactivateCmdAndEmit} className="btn-cmd-esc" type="button"><Text variant="b3">ESC</Text></button>
-        </div>
-        <div ref={rightOptionsRef} className="channel-input__option-container">
-          <IconButton
-            onClick={(e) => {
-              const boxInfo = e.target.getBoundingClientRect();
-              openEmojiBoard({
-                x: boxInfo.x + (document.dir === 'rtl' ? -80 : 80),
-                y: boxInfo.y - 250,
-                detail: e.detail,
-              }, addEmoji);
-            }}
-            tooltip="Emoji"
-            src={EmojiIC}
-          />
-          <IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
-        </div>
-      </>
-    );
-  }
-
-  function attachFile() {
-    const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
-    return (
-      <div className="channel-attachment">
-        <div className={`channel-attachment__preview${fileType !== 'image' ? ' channel-attachment__icon' : ''}`}>
-          {fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
-          {fileType === 'video' && <RawIcon src={VLCIC} />}
-          {fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
-          {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
-        </div>
-        <div className="channel-attachment__info">
-          <Text variant="b1">{attachment.name}</Text>
-          <Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
-        </div>
-      </div>
-    );
-  }
-
-  function attachReply() {
-    return (
-      <div className="channel-reply">
-        <IconButton
-          onClick={() => {
-            roomsInput.cancelReplyTo(roomId);
-            setReplyTo(null);
-          }}
-          src={CrossIC}
-          tooltip="Cancel reply"
-          size="extra-small"
-        />
-        <MessageReply
-          userId={replyTo.userId}
-          name={getUsername(replyTo.userId)}
-          color={colorMXID(replyTo.userId)}
-          content={replyTo.content}
-        />
-      </div>
-    );
-  }
-
-  return (
-    <>
-      { replyTo !== null && attachReply()}
-      { attachment !== null && attachFile() }
-      <form className="channel-input" onSubmit={(e) => { e.preventDefault(); }}>
-        {
-          roomTimeline.room.isSpaceRoom()
-            ? <Text className="channel-input__space" variant="b1">Spaces are yet to be implemented</Text>
-            : renderInputs()
-        }
-      </form>
-    </>
-  );
-}
-ChannelViewInput.propTypes = {
-  roomId: PropTypes.string.isRequired,
-  roomTimeline: PropTypes.shape({}).isRequired,
-  timelineScroll: PropTypes.shape({
-    reachBottom: PropTypes.func,
-    autoReachBottom: PropTypes.func,
-    tryRestoringScroll: PropTypes.func,
-    enableSmoothScroll: PropTypes.func,
-    disableSmoothScroll: PropTypes.func,
-  }).isRequired,
-  viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewInput;
diff --git a/src/app/organisms/channel/ChannelViewInput.scss b/src/app/organisms/channel/ChannelViewInput.scss
deleted file mode 100644 (file)
index 2bc0121..0000000
+++ /dev/null
@@ -1,128 +0,0 @@
-.channel-input {
-  padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
-  display: flex;
-  min-height: 48px;
-
-  &__space {
-    min-width: 0;
-    align-self: center;
-    margin: auto;
-    padding: 0 var(--sp-tight);
-  }
-
-  &__input-container {
-    flex: 1;
-    min-width: 0;
-    display: flex;
-    align-items: center;
-
-    margin: 0 calc(var(--sp-tight)  - 2px);
-    background-color: var(--bg-surface-low);
-    box-shadow: var(--bs-surface-border);
-    border-radius: var(--bo-radius);
-
-    & > .ic-raw {
-      transform: scale(0.8);
-      margin: 0 var(--sp-extra-tight);
-    }
-
-    & .btn-cmd-esc {
-      display: none;
-      margin: 0 var(--sp-extra-tight);
-      padding: var(--sp-ultra-tight) var(--sp-extra-tight);
-      background-color: var(--bg-surface);
-      border-radius: calc(var(--bo-radius) / 2);
-      box-shadow: var(--bs-surface-border);
-      cursor: pointer;
-      & .text { color: var(--tc-surface-normal); }
-    }
-    & .scrollbar {
-      max-height: 50vh;
-      flex: 1;
-
-      &:first-child {
-        margin-left: var(--sp-tight);
-        [dir=rtl] & {
-          margin-left: 0;
-          margin-right: var(--sp-tight);
-        }
-      }
-    }
-  }
-
-  &__textarea-wrapper {
-    min-height: 40px;
-    display: flex;
-    align-items: center;
-
-    & textarea {
-      resize: none;
-      width: 100%;
-      min-width: 0;
-      min-height: 100%;
-      padding: var(--sp-ultra-tight) 0;
-
-      &::placeholder {
-        color: var(--tc-surface-low);
-      }
-      &:focus {
-        outline: none;
-      }
-    }
-  }
-}
-
-.channel-attachment {
-  --side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
-  display: flex;
-  align-items: center;
-  margin-left: var(--side-spacing);
-  margin-top: var(--sp-extra-tight);
-  line-height: 0;
-  [dir=rtl] & {
-    margin-left: 0;
-    margin-right: var(--side-spacing);
-  }
-
-  &__preview > img {
-    max-height: 40px;
-    border-radius: var(--bo-radius);
-  }
-  &__icon {
-    padding: var(--sp-extra-tight);
-    background-color: var(--bg-surface-low);
-    box-shadow: var(--bs-surface-border);
-    border-radius: var(--bo-radius);
-  }
-  &__info {
-    flex: 1;
-    min-width: 0;
-    margin: 0 var(--sp-tight);
-  }
-
-  &__option button {
-    transition: transform 200ms ease-in-out;
-    transform: translateY(-48px);
-    & .ic-raw {
-      transition: transform 200ms ease-in-out;
-      transform: rotate(45deg);
-      background-color: var(--bg-caution);
-    }
-  }
-}
-
-.channel-reply {
-  display: flex;
-  align-items: center;
-  background-color: var(--bg-surface-low);
-  border-bottom: 1px solid var(--bg-surface-border);
-
-  & .ic-btn-surface {
-    margin: 0 13px 0 17px;
-    border-radius: 0;
-    [dir=rtl] & {
-      margin: 0 17px 0 13px;
-    }
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/channel/PeopleDrawer.jsx b/src/app/organisms/channel/PeopleDrawer.jsx
deleted file mode 100644 (file)
index 2a7b18d..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import './PeopleDrawer.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import { getUsernameOfRoomMember } from '../../../util/matrixUtil';
-import colorMXID from '../../../util/colorMXID';
-import { openInviteUser } from '../../../client/action/navigation';
-
-import Text from '../../atoms/text/Text';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import IconButton from '../../atoms/button/IconButton';
-import Button from '../../atoms/button/Button';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import Input from '../../atoms/input/Input';
-import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
-
-import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
-
-function getPowerLabel(powerLevel) {
-  switch (powerLevel) {
-    case 100:
-      return 'Admin';
-    case 50:
-      return 'Mod';
-    default:
-      return null;
-  }
-}
-function compare(m1, m2) {
-  let aName = m1.name;
-  let bName = m2.name;
-
-  // remove "#" from the room name
-  // To ignore it in sorting
-  aName = aName.replaceAll('#', '');
-  bName = bName.replaceAll('#', '');
-
-  if (aName.toLowerCase() < bName.toLowerCase()) {
-    return -1;
-  }
-  if (aName.toLowerCase() > bName.toLowerCase()) {
-    return 1;
-  }
-  return 0;
-}
-function sortByPowerLevel(m1, m2) {
-  let pl1 = String(m1.powerLevel);
-  let pl2 = String(m2.powerLevel);
-
-  if (pl1 === '100') pl1 = '90.9';
-  if (pl2 === '100') pl2 = '90.9';
-
-  if (pl1.toLowerCase() > pl2.toLowerCase()) {
-    return -1;
-  }
-  if (pl1.toLowerCase() < pl2.toLowerCase()) {
-    return 1;
-  }
-  return 0;
-}
-
-function PeopleDrawer({ roomId }) {
-  const PER_PAGE_MEMBER = 50;
-  const room = initMatrix.matrixClient.getRoom(roomId);
-  const totalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
-  const [memberList, updateMemberList] = useState([]);
-  let isRoomChanged = false;
-
-  function loadMorePeople() {
-    updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER));
-  }
-
-  useEffect(() => {
-    updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER));
-    room.loadMembersIfNeeded().then(() => {
-      if (isRoomChanged) return;
-      const newTotalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
-      updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER));
-    });
-
-    return () => {
-      isRoomChanged = true;
-    };
-  }, [roomId]);
-
-  return (
-    <div className="people-drawer">
-      <Header>
-        <TitleWrapper>
-          <Text variant="s1">
-            People
-            <Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
-          </Text>
-        </TitleWrapper>
-        <IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} />
-      </Header>
-      <div className="people-drawer__content-wrapper">
-        <div className="people-drawer__scrollable">
-          <ScrollView autoHide>
-            <div className="people-drawer__content">
-              {
-                memberList.map((member) => (
-                  <PeopleSelector
-                    key={member.userId}
-                    onClick={() => alert('Viewing profile is yet to be implemented')}
-                    avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
-                    name={getUsernameOfRoomMember(member)}
-                    color={colorMXID(member.userId)}
-                    peopleRole={getPowerLabel(member.powerLevel)}
-                  />
-                ))
-              }
-              <div className="people-drawer__load-more">
-                {
-                  memberList.length !== totalMemberList.length && (
-                    <Button onClick={loadMorePeople}>View more</Button>
-                  )
-                }
-              </div>
-            </div>
-          </ScrollView>
-        </div>
-        <div className="people-drawer__sticky">
-          <form onSubmit={(e) => e.preventDefault()} className="people-search">
-            <Input type="text" placeholder="Search" required />
-          </form>
-        </div>
-      </div>
-    </div>
-  );
-}
-
-PeopleDrawer.propTypes = {
-  roomId: PropTypes.string.isRequired,
-};
-
-export default PeopleDrawer;
diff --git a/src/app/organisms/channel/PeopleDrawer.scss b/src/app/organisms/channel/PeopleDrawer.scss
deleted file mode 100644 (file)
index 56ac29e..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-.people-drawer-flexBox {
-  display: flex;
-  flex-direction: column;
-}
-.people-drawer-flexItem {
-  flex: 1;
-  min-height: 0;
-  min-width: 0;
-}
-
-
-.people-drawer {
-  @extend .people-drawer-flexBox;
-  width: var(--people-drawer-width);
-  background-color: var(--bg-surface-low);
-  border-left: 1px solid var(--bg-surface-border);
-
-  [dir=rtl] & {
-    border: {
-      left: none;
-      right: 1px solid var(--bg-surface-hover);
-    }
-  }
-
-  &__member-count {
-    color: var(--tc-surface-low);
-  }
-
-  &__content-wrapper {
-    @extend .people-drawer-flexItem;
-    @extend .people-drawer-flexBox;
-  }
-
-  &__scrollable {
-    @extend .people-drawer-flexItem;
-  }
-
-  &__sticky {
-    display: none;
-
-    & .people-search {
-      min-height: 48px;
-  
-      margin: 0 var(--sp-normal);
-
-      position: relative;
-      bottom: var(--sp-normal);
-
-      & .input {
-        height: 48px;
-      }
-    }
-  }
-}
-
-.people-drawer__content {
-  padding-top: var(--sp-extra-tight);
-  padding-bottom: calc( var(--sp-extra-tight) + var(--sp-normal));
-}
-.people-drawer__load-more {
-  padding: var(--sp-normal);
-  padding: {
-    bottom: 0;
-    right: var(--sp-extra-tight);
-  }
-
-  [dir=rtl] & {
-    padding-right: var(--sp-normal);
-    padding-left: var(--sp-extra-tight);
-  }
-
-  & .btn-surface {
-    width: 100%;
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/channel/common.jsx b/src/app/organisms/channel/common.jsx
deleted file mode 100644 (file)
index 46fbc5d..0000000
+++ /dev/null
@@ -1,272 +0,0 @@
-import React from 'react';
-
-import initMatrix from '../../../client/initMatrix';
-import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
-
-function getTimelineJSXMessages() {
-  return {
-    join(user) {
-      return (
-        <>
-          <b>{user}</b>
-          {' joined the channel'}
-        </>
-      );
-    },
-    leave(user, reason) {
-      const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
-      return (
-        <>
-          <b>{user}</b>
-          {' left the channel'}
-          {reasonMsg}
-        </>
-      );
-    },
-    invite(inviter, user) {
-      return (
-        <>
-          <b>{inviter}</b>
-          {' invited '}
-          <b>{user}</b>
-        </>
-      );
-    },
-    cancelInvite(inviter, user) {
-      return (
-        <>
-          <b>{inviter}</b>
-          {' canceled '}
-          <b>{user}</b>
-          {'\'s invite'}
-        </>
-      );
-    },
-    rejectInvite(user) {
-      return (
-        <>
-          <b>{user}</b>
-          {' rejected the invitation'}
-        </>
-      );
-    },
-    kick(actor, user, reason) {
-      const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
-      return (
-        <>
-          <b>{actor}</b>
-          {' kicked '}
-          <b>{user}</b>
-          {reasonMsg}
-        </>
-      );
-    },
-    ban(actor, user, reason) {
-      const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
-      return (
-        <>
-          <b>{actor}</b>
-          {' banned '}
-          <b>{user}</b>
-          {reasonMsg}
-        </>
-      );
-    },
-    unban(actor, user) {
-      return (
-        <>
-          <b>{actor}</b>
-          {' unbanned '}
-          <b>{user}</b>
-        </>
-      );
-    },
-    avatarSets(user) {
-      return (
-        <>
-          <b>{user}</b>
-          {' set the avatar'}
-        </>
-      );
-    },
-    avatarChanged(user) {
-      return (
-        <>
-          <b>{user}</b>
-          {' changed the avatar'}
-        </>
-      );
-    },
-    avatarRemoved(user) {
-      return (
-        <>
-          <b>{user}</b>
-          {' removed the avatar'}
-        </>
-      );
-    },
-    nameSets(user, newName) {
-      return (
-        <>
-          <b>{user}</b>
-          {' set the display name to '}
-          <b>{newName}</b>
-        </>
-      );
-    },
-    nameChanged(user, newName) {
-      return (
-        <>
-          <b>{user}</b>
-          {' changed the display name to '}
-          <b>{newName}</b>
-        </>
-      );
-    },
-    nameRemoved(user, lastName) {
-      return (
-        <>
-          <b>{user}</b>
-          {' removed the display name '}
-          <b>{lastName}</b>
-        </>
-      );
-    },
-  };
-}
-
-function getUsersActionJsx(roomId, userIds, actionStr) {
-  const room = initMatrix.matrixClient.getRoom(roomId);
-  const getUserDisplayName = (userId) => {
-    if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
-    return getUsername(userId);
-  };
-  const getUserJSX = (userId) => <b>{getUserDisplayName(userId)}</b>;
-  if (!Array.isArray(userIds)) return 'Idle';
-  if (userIds.length === 0) return 'Idle';
-  const MAX_VISIBLE_COUNT = 3;
-
-  const u1Jsx = getUserJSX(userIds[0]);
-  // eslint-disable-next-line react/jsx-one-expression-per-line
-  if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
-
-  const u2Jsx = getUserJSX(userIds[1]);
-  // eslint-disable-next-line react/jsx-one-expression-per-line
-  if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
-
-  const u3Jsx = getUserJSX(userIds[2]);
-  if (userIds.length === 3) {
-    // eslint-disable-next-line react/jsx-one-expression-per-line
-    return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
-  }
-
-  const othersCount = userIds.length - MAX_VISIBLE_COUNT;
-  // eslint-disable-next-line react/jsx-one-expression-per-line
-  return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}</>;
-}
-
-function parseReply(rawContent) {
-  if (rawContent.indexOf('>') !== 0) return null;
-  let content = rawContent.slice(rawContent.indexOf('<') + 1);
-  const user = content.slice(0, content.indexOf('>'));
-
-  content = content.slice(content.indexOf('>') + 2);
-  const replyContent = content.slice(0, content.indexOf('\n\n'));
-  content = content.slice(content.indexOf('\n\n') + 2);
-
-  if (user === '') return null;
-
-  const isUserId = user.match(/^@.+:.+/);
-
-  return {
-    userId: isUserId ? user : null,
-    displayName: isUserId ? null : user,
-    replyContent,
-    content,
-  };
-}
-
-function parseTimelineChange(mEvent) {
-  const tJSXMsgs = getTimelineJSXMessages();
-  const makeReturnObj = (variant, content) => ({
-    variant,
-    content,
-  });
-  const content = mEvent.getContent();
-  const prevContent = mEvent.getPrevContent();
-  const sender = mEvent.getSender();
-  const senderName = getUsername(sender);
-  const userName = getUsername(mEvent.getStateKey());
-
-  switch (content.membership) {
-    case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
-    case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
-    case 'join':
-      if (prevContent.membership === 'join') {
-        if (content.displayname !== prevContent.displayname) {
-          if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
-          if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
-          return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
-        }
-        if (content.avatar_url !== prevContent.avatar_url) {
-          if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
-          if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
-          return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
-        }
-        return null;
-      }
-      return makeReturnObj('join', tJSXMsgs.join(senderName));
-    case 'leave':
-      if (sender === mEvent.getStateKey()) {
-        switch (prevContent.membership) {
-          case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
-          default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
-        }
-      }
-      switch (prevContent.membership) {
-        case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
-        case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
-        // sender is not target and made the target leave,
-        // if not from invite/ban then this is a kick
-        default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
-      }
-    default: return null;
-  }
-}
-
-function scrollToBottom(ref) {
-  const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight;
-  // eslint-disable-next-line no-param-reassign
-  ref.current.scrollTop = maxScrollTop;
-}
-
-function isAtBottom(ref) {
-  const { scrollHeight, scrollTop, offsetHeight } = ref.current;
-  const scrollUptoBottom = scrollTop + offsetHeight;
-
-  // scroll view have to div inside div which contains messages
-  const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild;
-  const lastChildHeight = lastMessage.offsetHeight;
-
-  // auto scroll to bottom even if user has EXTRA_SPACE left to scroll
-  const EXTRA_SPACE = 48;
-
-  if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) {
-    return true;
-  }
-  return false;
-}
-
-function autoScrollToBottom(ref) {
-  if (isAtBottom(ref)) scrollToBottom(ref);
-}
-
-export {
-  getTimelineJSXMessages,
-  getUsersActionJsx,
-  parseReply,
-  parseTimelineChange,
-  scrollToBottom,
-  isAtBottom,
-  autoScrollToBottom,
-};
diff --git a/src/app/organisms/create-channel/CreateChannel.jsx b/src/app/organisms/create-channel/CreateChannel.jsx
deleted file mode 100644 (file)
index c44b536..0000000
+++ /dev/null
@@ -1,165 +0,0 @@
-import React, { useState, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './CreateChannel.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import { isRoomAliasAvailable } from '../../../util/matrixUtil';
-import * as roomActions from '../../../client/action/room';
-
-import Text from '../../atoms/text/Text';
-import Button from '../../atoms/button/Button';
-import Toggle from '../../atoms/button/Toggle';
-import IconButton from '../../atoms/button/IconButton';
-import Input from '../../atoms/input/Input';
-import Spinner from '../../atoms/spinner/Spinner';
-import PopupWindow from '../../molecules/popup-window/PopupWindow';
-import SettingTile from '../../molecules/setting-tile/SettingTile';
-
-import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
-import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-
-function CreateChannel({ isOpen, onRequestClose }) {
-  const [isPublic, togglePublic] = useState(false);
-  const [isEncrypted, toggleEncrypted] = useState(true);
-  const [isValidAddress, updateIsValidAddress] = useState(null);
-  const [isCreatingRoom, updateIsCreatingRoom] = useState(false);
-  const [creatingError, updateCreatingError] = useState(null);
-
-  const [titleValue, updateTitleValue] = useState(undefined);
-  const [topicValue, updateTopicValue] = useState(undefined);
-  const [addressValue, updateAddressValue] = useState(undefined);
-
-  const addressRef = useRef(null);
-  const topicRef = useRef(null);
-  const nameRef = useRef(null);
-
-  const userId = initMatrix.matrixClient.getUserId();
-  const hsString = userId.slice(userId.indexOf(':'));
-
-  function resetForm() {
-    togglePublic(false);
-    toggleEncrypted(true);
-    updateIsValidAddress(null);
-    updateIsCreatingRoom(false);
-    updateCreatingError(null);
-    updateTitleValue(undefined);
-    updateTopicValue(undefined);
-    updateAddressValue(undefined);
-  }
-
-  async function createRoom() {
-    if (isCreatingRoom) return;
-    updateIsCreatingRoom(true);
-    updateCreatingError(null);
-    const name = nameRef.current.value;
-    let topic = topicRef.current.value;
-    if (topic.trim() === '') topic = undefined;
-    let roomAlias;
-    if (isPublic) {
-      roomAlias = addressRef?.current?.value;
-      if (roomAlias.trim() === '') roomAlias = undefined;
-    }
-
-    try {
-      await roomActions.create({
-        name, topic, isPublic, roomAlias, isEncrypted,
-      });
-
-      resetForm();
-      onRequestClose();
-    } catch (e) {
-      if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
-        updateCreatingError('ERROR: Invalid characters in channel address');
-        updateIsValidAddress(false);
-      } else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
-        updateCreatingError('ERROR: Channel address is already in use');
-        updateIsValidAddress(false);
-      } else updateCreatingError(e.message);
-    }
-    updateIsCreatingRoom(false);
-  }
-
-  function validateAddress(e) {
-    const myAddress = e.target.value;
-    updateIsValidAddress(null);
-    updateAddressValue(e.target.value);
-    updateCreatingError(null);
-
-    setTimeout(async () => {
-      if (myAddress !== addressRef.current.value) return;
-      const roomAlias = addressRef.current.value;
-      if (roomAlias === '') return;
-      const roomAddress = `#${roomAlias}${hsString}`;
-
-      if (await isRoomAliasAvailable(roomAddress)) {
-        updateIsValidAddress(true);
-      } else {
-        updateIsValidAddress(false);
-      }
-    }, 1000);
-  }
-  function handleTitleChange(e) {
-    if (e.target.value.trim() === '') updateTitleValue(undefined);
-    updateTitleValue(e.target.value);
-  }
-  function handleTopicChange(e) {
-    if (e.target.value.trim() === '') updateTopicValue(undefined);
-    updateTopicValue(e.target.value);
-  }
-
-  return (
-    <PopupWindow
-      isOpen={isOpen}
-      title="Create channel"
-      contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
-      onRequestClose={onRequestClose}
-    >
-      <div className="create-channel">
-        <form className="create-channel__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
-          <SettingTile
-            title="Make channel public"
-            options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
-            content={<Text variant="b3">Public channel can be joined by anyone.</Text>}
-          />
-          {isPublic && (
-            <div>
-              <Text className="create-channel__address__label" variant="b2">Channel address</Text>
-              <div className="create-channel__address">
-                <Text variant="b1">#</Text>
-                <Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
-                <Text variant="b1">{hsString}</Text>
-              </div>
-              {isValidAddress === false && <Text className="create-channel__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
-            </div>
-          )}
-          {!isPublic && (
-            <SettingTile
-              title="Enable end-to-end encryption"
-              options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
-              content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
-            />
-          )}
-          <Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
-          <div className="create-channel__name-wrapper">
-            <Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Channel name" required />
-            <Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
-          </div>
-          {isCreatingRoom && (
-            <div className="create-channel__loading">
-              <Spinner size="small" />
-              <Text>Creating channel...</Text>
-            </div>
-          )}
-          {typeof creatingError === 'string' && <Text className="create-channel__error" variant="b3">{creatingError}</Text>}
-        </form>
-      </div>
-    </PopupWindow>
-  );
-}
-
-CreateChannel.propTypes = {
-  isOpen: PropTypes.bool.isRequired,
-  onRequestClose: PropTypes.func.isRequired,
-};
-
-export default CreateChannel;
diff --git a/src/app/organisms/create-channel/CreateChannel.scss b/src/app/organisms/create-channel/CreateChannel.scss
deleted file mode 100644 (file)
index 6d59f65..0000000
+++ /dev/null
@@ -1,103 +0,0 @@
-.create-channel {
-  margin: 0 var(--sp-normal);
-  margin-right: var(--sp-extra-tight);
-
-  &__form > * {
-    margin-top: var(--sp-normal);
-    &:first-child {
-      margin-top: var(--sp-extra-tight);
-    }
-  }
-
-  &__address {
-    display: flex;
-    &__label {
-      color: var(--tc-surface-low);
-      margin-bottom: var(--sp-ultra-tight);
-    }
-    &__tip {
-      margin-left: 46px;
-      margin-top: var(--sp-ultra-tight);
-      [dir=rtl] & {
-        margin-left: 0;
-        margin-right: 46px;
-      }
-    }
-    & .text {
-      display: flex;
-      align-items: center;
-      padding: 0 var(--sp-normal);
-      border: 1px solid var(--bg-surface-border);
-      border-radius: var(--bo-radius);
-      color: var(--tc-surface-low);
-    }
-    & *:nth-child(2) {
-      flex: 1;
-      min-width: 0;
-      & .input {
-        border-radius: 0;
-      }
-    }
-    & .text:first-child {
-      border-right-width: 0;
-      border-radius: var(--bo-radius) 0 0 var(--bo-radius);
-    }
-    & .text:last-child {
-      border-left-width: 0;
-      border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
-    }
-    [dir=rtl] & {
-      & .text:first-child {
-        border-left-width: 0;
-        border-right-width: 1px;
-        border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
-      }
-      & .text:last-child {
-        border-right-width: 0;
-        border-left-width: 1px;
-        border-radius: var(--bo-radius) 0 0 var(--bo-radius);
-      }
-    }
-  }
-
-  &__name-wrapper {
-    display: flex;
-    align-items: flex-end;
-
-    & .input-container {
-      flex: 1;
-      min-width: 0;
-      margin-right: var(--sp-normal);
-      [dir=rtl] & {
-        margin-right: 0;
-        margin-left: var(--sp-normal);
-      }
-    }
-    & .btn-primary {
-      padding-top: 11px;
-      padding-bottom: 11px;
-    }
-  }
-
-  &__loading {
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    & .text {
-      margin-left: var(--sp-normal);
-      [dir=rtl] & {
-        margin-left: 0;
-        margin-right: var(--sp-normal);
-      }
-    }
-  }
-  &__error {
-    text-align: center;
-    color: var(--bg-danger) !important;
-  }
-
-  [dir=rtl] & {
-    margin-right: var(--sp-normal);
-    margin-left: var(--sp-extra-tight);
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/create-room/CreateRoom.jsx b/src/app/organisms/create-room/CreateRoom.jsx
new file mode 100644 (file)
index 0000000..d94c4b1
--- /dev/null
@@ -0,0 +1,165 @@
+import React, { useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './CreateRoom.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import { isRoomAliasAvailable } from '../../../util/matrixUtil';
+import * as roomActions from '../../../client/action/room';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import Toggle from '../../atoms/button/Toggle';
+import IconButton from '../../atoms/button/IconButton';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+import PopupWindow from '../../molecules/popup-window/PopupWindow';
+import SettingTile from '../../molecules/setting-tile/SettingTile';
+
+import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+
+function CreateRoom({ isOpen, onRequestClose }) {
+  const [isPublic, togglePublic] = useState(false);
+  const [isEncrypted, toggleEncrypted] = useState(true);
+  const [isValidAddress, updateIsValidAddress] = useState(null);
+  const [isCreatingRoom, updateIsCreatingRoom] = useState(false);
+  const [creatingError, updateCreatingError] = useState(null);
+
+  const [titleValue, updateTitleValue] = useState(undefined);
+  const [topicValue, updateTopicValue] = useState(undefined);
+  const [addressValue, updateAddressValue] = useState(undefined);
+
+  const addressRef = useRef(null);
+  const topicRef = useRef(null);
+  const nameRef = useRef(null);
+
+  const userId = initMatrix.matrixClient.getUserId();
+  const hsString = userId.slice(userId.indexOf(':'));
+
+  function resetForm() {
+    togglePublic(false);
+    toggleEncrypted(true);
+    updateIsValidAddress(null);
+    updateIsCreatingRoom(false);
+    updateCreatingError(null);
+    updateTitleValue(undefined);
+    updateTopicValue(undefined);
+    updateAddressValue(undefined);
+  }
+
+  async function createRoom() {
+    if (isCreatingRoom) return;
+    updateIsCreatingRoom(true);
+    updateCreatingError(null);
+    const name = nameRef.current.value;
+    let topic = topicRef.current.value;
+    if (topic.trim() === '') topic = undefined;
+    let roomAlias;
+    if (isPublic) {
+      roomAlias = addressRef?.current?.value;
+      if (roomAlias.trim() === '') roomAlias = undefined;
+    }
+
+    try {
+      await roomActions.create({
+        name, topic, isPublic, roomAlias, isEncrypted,
+      });
+
+      resetForm();
+      onRequestClose();
+    } catch (e) {
+      if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
+        updateCreatingError('ERROR: Invalid characters in room address');
+        updateIsValidAddress(false);
+      } else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
+        updateCreatingError('ERROR: Room address is already in use');
+        updateIsValidAddress(false);
+      } else updateCreatingError(e.message);
+    }
+    updateIsCreatingRoom(false);
+  }
+
+  function validateAddress(e) {
+    const myAddress = e.target.value;
+    updateIsValidAddress(null);
+    updateAddressValue(e.target.value);
+    updateCreatingError(null);
+
+    setTimeout(async () => {
+      if (myAddress !== addressRef.current.value) return;
+      const roomAlias = addressRef.current.value;
+      if (roomAlias === '') return;
+      const roomAddress = `#${roomAlias}${hsString}`;
+
+      if (await isRoomAliasAvailable(roomAddress)) {
+        updateIsValidAddress(true);
+      } else {
+        updateIsValidAddress(false);
+      }
+    }, 1000);
+  }
+  function handleTitleChange(e) {
+    if (e.target.value.trim() === '') updateTitleValue(undefined);
+    updateTitleValue(e.target.value);
+  }
+  function handleTopicChange(e) {
+    if (e.target.value.trim() === '') updateTopicValue(undefined);
+    updateTopicValue(e.target.value);
+  }
+
+  return (
+    <PopupWindow
+      isOpen={isOpen}
+      title="Create room"
+      contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+      onRequestClose={onRequestClose}
+    >
+      <div className="create-room">
+        <form className="create-room__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
+          <SettingTile
+            title="Make room public"
+            options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
+            content={<Text variant="b3">Public room can be joined by anyone.</Text>}
+          />
+          {isPublic && (
+            <div>
+              <Text className="create-room__address__label" variant="b2">Room address</Text>
+              <div className="create-room__address">
+                <Text variant="b1">#</Text>
+                <Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
+                <Text variant="b1">{hsString}</Text>
+              </div>
+              {isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
+            </div>
+          )}
+          {!isPublic && (
+            <SettingTile
+              title="Enable end-to-end encryption"
+              options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
+              content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
+            />
+          )}
+          <Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
+          <div className="create-room__name-wrapper">
+            <Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Room name" required />
+            <Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
+          </div>
+          {isCreatingRoom && (
+            <div className="create-room__loading">
+              <Spinner size="small" />
+              <Text>Creating room...</Text>
+            </div>
+          )}
+          {typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
+        </form>
+      </div>
+    </PopupWindow>
+  );
+}
+
+CreateRoom.propTypes = {
+  isOpen: PropTypes.bool.isRequired,
+  onRequestClose: PropTypes.func.isRequired,
+};
+
+export default CreateRoom;
diff --git a/src/app/organisms/create-room/CreateRoom.scss b/src/app/organisms/create-room/CreateRoom.scss
new file mode 100644 (file)
index 0000000..c587fa2
--- /dev/null
@@ -0,0 +1,103 @@
+.create-room {
+  margin: 0 var(--sp-normal);
+  margin-right: var(--sp-extra-tight);
+
+  &__form > * {
+    margin-top: var(--sp-normal);
+    &:first-child {
+      margin-top: var(--sp-extra-tight);
+    }
+  }
+
+  &__address {
+    display: flex;
+    &__label {
+      color: var(--tc-surface-low);
+      margin-bottom: var(--sp-ultra-tight);
+    }
+    &__tip {
+      margin-left: 46px;
+      margin-top: var(--sp-ultra-tight);
+      [dir=rtl] & {
+        margin-left: 0;
+        margin-right: 46px;
+      }
+    }
+    & .text {
+      display: flex;
+      align-items: center;
+      padding: 0 var(--sp-normal);
+      border: 1px solid var(--bg-surface-border);
+      border-radius: var(--bo-radius);
+      color: var(--tc-surface-low);
+    }
+    & *:nth-child(2) {
+      flex: 1;
+      min-width: 0;
+      & .input {
+        border-radius: 0;
+      }
+    }
+    & .text:first-child {
+      border-right-width: 0;
+      border-radius: var(--bo-radius) 0 0 var(--bo-radius);
+    }
+    & .text:last-child {
+      border-left-width: 0;
+      border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
+    }
+    [dir=rtl] & {
+      & .text:first-child {
+        border-left-width: 0;
+        border-right-width: 1px;
+        border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
+      }
+      & .text:last-child {
+        border-right-width: 0;
+        border-left-width: 1px;
+        border-radius: var(--bo-radius) 0 0 var(--bo-radius);
+      }
+    }
+  }
+
+  &__name-wrapper {
+    display: flex;
+    align-items: flex-end;
+
+    & .input-container {
+      flex: 1;
+      min-width: 0;
+      margin-right: var(--sp-normal);
+      [dir=rtl] & {
+        margin-right: 0;
+        margin-left: var(--sp-normal);
+      }
+    }
+    & .btn-primary {
+      padding-top: 11px;
+      padding-bottom: 11px;
+    }
+  }
+
+  &__loading {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    & .text {
+      margin-left: var(--sp-normal);
+      [dir=rtl] & {
+        margin-left: 0;
+        margin-right: var(--sp-normal);
+      }
+    }
+  }
+  &__error {
+    text-align: center;
+    color: var(--bg-danger) !important;
+  }
+
+  [dir=rtl] & {
+    margin-right: var(--sp-normal);
+    margin-left: var(--sp-extra-tight);
+  }
+}
\ No newline at end of file
index 297478e96ec7a3ac881d44dec631f72101a1d957..2fee05085cb3f74895b1cb736b861a3cce01ac51 100644 (file)
@@ -11,7 +11,7 @@ import Button from '../../atoms/button/Button';
 import IconButton from '../../atoms/button/IconButton';
 import Spinner from '../../atoms/spinner/Spinner';
 import PopupWindow from '../../molecules/popup-window/PopupWindow';
-import ChannelTile from '../../molecules/channel-tile/ChannelTile';
+import RoomTile from '../../molecules/room-tile/RoomTile';
 
 import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
 
@@ -47,13 +47,13 @@ function InviteList({ isOpen, onRequestClose }) {
     };
   }, [procInvite]);
 
-  function renderChannelTile(roomId) {
+  function renderRoomTile(roomId) {
     const myRoom = initMatrix.matrixClient.getRoom(roomId);
     const roomName = myRoom.name;
     let roomAlias = myRoom.getCanonicalAlias();
     if (roomAlias === null) roomAlias = myRoom.roomId;
     return (
-      <ChannelTile
+      <RoomTile
         key={myRoom.roomId}
         name={roomName}
         avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
@@ -91,7 +91,7 @@ function InviteList({ isOpen, onRequestClose }) {
             const myRoom = initMatrix.matrixClient.getRoom(roomId);
             const roomName = myRoom.name;
             return (
-              <ChannelTile
+              <RoomTile
                 key={myRoom.roomId}
                 name={roomName}
                 id={myRoom.getDMInviter()}
@@ -114,14 +114,14 @@ function InviteList({ isOpen, onRequestClose }) {
             <Text variant="b3">Spaces</Text>
           </div>
         )}
-        { Array.from(initMatrix.roomList.inviteSpaces).map(renderChannelTile) }
+        { Array.from(initMatrix.roomList.inviteSpaces).map(renderRoomTile) }
 
         { initMatrix.roomList.inviteRooms.size !== 0 && (
           <div className="invites-content__subheading">
-            <Text variant="b3">Channels</Text>
+            <Text variant="b3">Rooms</Text>
           </div>
         )}
-        { Array.from(initMatrix.roomList.inviteRooms).map(renderChannelTile) }
+        { Array.from(initMatrix.roomList.inviteRooms).map(renderRoomTile) }
       </div>
     </PopupWindow>
   );
index bdb78c415f8dca23b26c8c037f2dab5385fa45aa..70e82c76c7757f6e4b793b6e2cd2b01f00cf2555 100644 (file)
@@ -14,7 +14,7 @@
     }
   }
 
-  & .channel-tile {
+  & .room-tile {
     margin-top: var(--sp-normal);
     &__options {
       align-self: flex-end;
index cac9060789211135149e3f5676996a8090e89f12..a6ff24210b2f9f6513c93f1455af4f3875aac39e 100644 (file)
@@ -13,7 +13,7 @@ import IconButton from '../../atoms/button/IconButton';
 import Spinner from '../../atoms/spinner/Spinner';
 import Input from '../../atoms/input/Input';
 import PopupWindow from '../../molecules/popup-window/PopupWindow';
-import ChannelTile from '../../molecules/channel-tile/ChannelTile';
+import RoomTile from '../../molecules/room-tile/RoomTile';
 
 import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
 import UserIC from '../../../../public/res/ic/outlined/user.svg';
@@ -188,7 +188,7 @@ function InviteUser({
       const userId = user.user_id;
       const name = typeof user.display_name === 'string' ? user.display_name : userId;
       return (
-        <ChannelTile
+        <RoomTile
           key={userId}
           avatarSrc={typeof user.avatar_url === 'string' ? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop') : null}
           name={name}
index cfef9a3fce959514016e56a71f60dbb9f4e5ca87..08313c7a75cab3b6c4dd2a028715e1a8b8aff7cf 100644 (file)
@@ -39,7 +39,7 @@
     border-top: 1px solid var(--bg-surface-border);
   }
 
-  & .channel-tile {
+  & .room-tile {
     margin-top: var(--sp-normal);
     &__options {
       align-self: flex-end;
index 92c192a2d47cd645292f509643eb4895f8493115..d1dd011df81b4e5e06515734980a267fd7af6e97 100644 (file)
@@ -40,9 +40,9 @@ function Drawer() {
       <DrawerHeader activeTab={activeTab} />
       <div className="drawer__content-wrapper">
         <DrawerBradcrumb />
-        <div className="channels__wrapper">
+        <div className="rooms__wrapper">
           <ScrollView autoHide>
-            <div className="channels-container">
+            <div className="rooms-container">
               {
                 activeTab === 'home'
                   ? <Home />
index 4b12bcea6251ce26d0973e56c8428fc87e7b590d..e5d3f7104903ded5ea1193f9b106b57724f8086c 100644 (file)
   display: none;
   height: var(--header-height);
 }
-.channels__wrapper {
+.rooms__wrapper {
   @extend .drawer-flexItem;
 }
 
-.channels-container {
+.rooms-container {
   padding-bottom: var(--sp-extra-loose);
 
-  & > .channel-selector {
+  & > .room-selector {
     width: calc(100% - var(--sp-extra-tight));
     margin-left: auto;
 
@@ -46,7 +46,7 @@
 
   }
 
-  & > .channel-selector:first-child {
+  & > .room-selector:first-child {
     margin-top: var(--sp-extra-tight);
   }
 
index c86b09b321c2a3f61286e6ceff96abdfbbcf23c3..8915536719b14d79312883576c1c49d2c7e44edf 100644 (file)
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import {
-  openPublicChannels, openCreateChannel, openInviteUser,
+  openPublicRooms, openCreateRoom, openInviteUser,
 } from '../../../client/action/navigation';
 
 import Text from '../../atoms/text/Text';
@@ -26,22 +26,22 @@ function DrawerHeader({ activeTab }) {
           <ContextMenu
             content={(hideMenu) => (
               <>
-                <MenuHeader>Add channel</MenuHeader>
+                <MenuHeader>Add room</MenuHeader>
                 <MenuItem
                   iconSrc={HashPlusIC}
-                  onClick={() => { hideMenu(); openCreateChannel(); }}
+                  onClick={() => { hideMenu(); openCreateRoom(); }}
                 >
-                  Create new channel
+                  Create new room
                 </MenuItem>
                 <MenuItem
                   iconSrc={HashSearchIC}
-                  onClick={() => { hideMenu(); openPublicChannels(); }}
+                  onClick={() => { hideMenu(); openPublicRooms(); }}
                 >
-                  Add Public channel
+                  Add public room
                 </MenuItem>
               </>
             )}
-            render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add channel" src={PlusIC} size="normal" />)}
+            render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add room" src={PlusIC} size="normal" />)}
           />
         )}
       {/* <IconButton onClick={() => ''} tooltip="Menu" src={VerticalMenuIC} size="normal" /> */}
index 80cd3c0ba77ef3732a481e5c86151a4ac9dd0910..a39ad5d9a84d491dc239bfe085ba0bb7c8eedbeb 100644 (file)
@@ -70,7 +70,7 @@ function Home() {
         />
       ))}
 
-      { roomIds.length !== 0 && <Text className="cat-header" variant="b3">Channels</Text> }
+      { roomIds.length !== 0 && <Text className="cat-header" variant="b3">Rooms</Text> }
       { roomIds.map((id) => (
         <Selector
           key={id}
index c90fc85457360d9816ca47bd95b355e67350e6cd..3c45d15453418455ca7c1246e34322f4eaf6baff 100644 (file)
@@ -7,7 +7,7 @@ import { doesRoomHaveUnread } from '../../../util/matrixUtil';
 import { selectRoom } from '../../../client/action/navigation';
 import navigation from '../../../client/state/navigation';
 
-import ChannelSelector from '../../molecules/channel-selector/ChannelSelector';
+import RoomSelector from '../../molecules/room-selector/RoomSelector';
 
 import HashIC from '../../../../public/res/ic/outlined/hash.svg';
 import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
@@ -39,7 +39,7 @@ function Selector({ roomId, isDM, drawerPostie }) {
   }, []);
 
   return (
-    <ChannelSelector
+    <RoomSelector
       key={roomId}
       name={room.name}
       roomId={roomId}
index adb8f510152e2d9138385484937dc4e1c5abcb89..7d3511baed916b051ce523afdaf47a08200db5db 100644 (file)
@@ -6,7 +6,7 @@ import cons from '../../../client/state/cons';
 import colorMXID from '../../../util/colorMXID';
 import logout from '../../../client/action/logout';
 import {
-  changeTab, openInviteList, openPublicChannels, openSettings,
+  changeTab, openInviteList, openPublicRooms, openSettings,
 } from '../../../client/action/navigation';
 import navigation from '../../../client/state/navigation';
 
@@ -92,8 +92,8 @@ function SideBar() {
           <div className="scrollable-content">
             <div className="featured-container">
               <SidebarAvatar active={activeTab === 'home'} onClick={() => changeTab('home')} tooltip="Home" iconSrc={HomeIC} />
-              <SidebarAvatar active={activeTab === 'dms'} onClick={() => changeTab('dms')} tooltip="People" iconSrc={UserIC} />
-              <SidebarAvatar onClick={() => openPublicChannels()} tooltip="Public channels" iconSrc={HashSearchIC} />
+              <SidebarAvatar active={activeTab === 'dm'} onClick={() => changeTab('dm')} tooltip="People" iconSrc={UserIC} />
+              <SidebarAvatar onClick={() => openPublicRooms()} tooltip="Public rooms" iconSrc={HashSearchIC} />
             </div>
             <div className="sidebar-divider" />
             <div className="space-container" />
diff --git a/src/app/organisms/public-channels/PublicChannels.jsx b/src/app/organisms/public-channels/PublicChannels.jsx
deleted file mode 100644 (file)
index b7388e5..0000000
+++ /dev/null
@@ -1,287 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './PublicChannels.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import { selectRoom } from '../../../client/action/navigation';
-import * as roomActions from '../../../client/action/room';
-
-import Text from '../../atoms/text/Text';
-import Button from '../../atoms/button/Button';
-import IconButton from '../../atoms/button/IconButton';
-import Spinner from '../../atoms/spinner/Spinner';
-import Input from '../../atoms/input/Input';
-import PopupWindow from '../../molecules/popup-window/PopupWindow';
-import ChannelTile from '../../molecules/channel-tile/ChannelTile';
-
-import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
-
-const SEARCH_LIMIT = 20;
-
-function TryJoinWithAlias({ alias, onRequestClose }) {
-  const [status, setStatus] = useState({
-    isJoining: false,
-    error: null,
-    roomId: null,
-    tempRoomId: null,
-  });
-  function handleOnRoomAdded(roomId) {
-    if (status.tempRoomId !== null && status.tempRoomId !== roomId) return;
-    setStatus({
-      isJoining: false, error: null, roomId, tempRoomId: null,
-    });
-  }
-
-  useEffect(() => {
-    initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
-    return () => {
-      initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
-    };
-  }, [status]);
-
-  async function joinWithAlias() {
-    setStatus({
-      isJoining: true, error: null, roomId: null, tempRoomId: null,
-    });
-    try {
-      const roomId = await roomActions.join(alias, false);
-      setStatus({
-        isJoining: true, error: null, roomId: null, tempRoomId: roomId,
-      });
-    } catch (e) {
-      setStatus({
-        isJoining: false,
-        error: `Unable to join ${alias}. Either channel is private or doesn't exist.`,
-        roomId: null,
-        tempRoomId: null,
-      });
-    }
-  }
-
-  return (
-    <div className="try-join-with-alias">
-      {status.roomId === null && !status.isJoining && status.error === null && (
-        <Button onClick={() => joinWithAlias()}>{`Try joining ${alias}`}</Button>
-      )}
-      {status.isJoining && (
-        <>
-          <Spinner size="small" />
-          <Text>{`Joining ${alias}...`}</Text>
-        </>
-      )}
-      {status.roomId !== null && (
-        <Button onClick={() => { onRequestClose(); selectRoom(status.roomId); }}>Open</Button>
-      )}
-      {status.error !== null && <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{status.error}</span></Text>}
-    </div>
-  );
-}
-
-TryJoinWithAlias.propTypes = {
-  alias: PropTypes.string.isRequired,
-  onRequestClose: PropTypes.func.isRequired,
-};
-
-function PublicChannels({ isOpen, searchTerm, onRequestClose }) {
-  const [isSearching, updateIsSearching] = useState(false);
-  const [isViewMore, updateIsViewMore] = useState(false);
-  const [publicChannels, updatePublicChannels] = useState([]);
-  const [nextBatch, updateNextBatch] = useState(undefined);
-  const [searchQuery, updateSearchQuery] = useState({});
-  const [joiningChannels, updateJoiningChannels] = useState(new Set());
-
-  const channelNameRef = useRef(null);
-  const hsRef = useRef(null);
-  const userId = initMatrix.matrixClient.getUserId();
-
-  async function searchChannels(viewMore) {
-    let inputChannelName = channelNameRef?.current?.value || searchTerm;
-    let isInputAlias = false;
-    if (typeof inputChannelName === 'string') {
-      isInputAlias = inputChannelName[0] === '#' && inputChannelName.indexOf(':') > 1;
-    }
-    const hsFromAlias = (isInputAlias) ? inputChannelName.slice(inputChannelName.indexOf(':') + 1) : null;
-    let inputHs = hsFromAlias || hsRef?.current?.value;
-
-    if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1);
-    if (typeof inputChannelName !== 'string') inputChannelName = '';
-
-    if (isSearching) return;
-    if (viewMore !== true
-      && inputChannelName === searchQuery.name
-      && inputHs === searchQuery.homeserver
-    ) return;
-
-    updateSearchQuery({
-      name: inputChannelName,
-      homeserver: inputHs,
-    });
-    if (isViewMore !== viewMore) updateIsViewMore(viewMore);
-    updateIsSearching(true);
-
-    try {
-      const result = await initMatrix.matrixClient.publicRooms({
-        server: inputHs,
-        limit: SEARCH_LIMIT,
-        since: viewMore ? nextBatch : undefined,
-        include_all_networks: true,
-        filter: {
-          generic_search_term: inputChannelName,
-        },
-      });
-
-      const totalChannels = viewMore ? publicChannels.concat(result.chunk) : result.chunk;
-      updatePublicChannels(totalChannels);
-      updateNextBatch(result.next_batch);
-      updateIsSearching(false);
-      updateIsViewMore(false);
-      if (totalChannels.length === 0) {
-        updateSearchQuery({
-          error: `No result found for "${inputChannelName}" on ${inputHs}`,
-          alias: isInputAlias ? inputChannelName : null,
-        });
-      }
-    } catch (e) {
-      updatePublicChannels([]);
-      updateSearchQuery({
-        error: 'Something went wrong!',
-        alias: isInputAlias ? inputChannelName : null,
-      });
-      updateIsSearching(false);
-      updateNextBatch(undefined);
-      updateIsViewMore(false);
-    }
-  }
-
-  useEffect(() => {
-    if (isOpen) searchChannels();
-  }, [isOpen]);
-
-  function handleOnRoomAdded(roomId) {
-    if (joiningChannels.has(roomId)) {
-      joiningChannels.delete(roomId);
-      updateJoiningChannels(new Set(Array.from(joiningChannels)));
-    }
-  }
-  useEffect(() => {
-    initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
-    return () => {
-      initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
-    };
-  }, [joiningChannels]);
-
-  function handleViewChannel(roomId) {
-    selectRoom(roomId);
-    onRequestClose();
-  }
-
-  function joinChannel(roomIdOrAlias) {
-    joiningChannels.add(roomIdOrAlias);
-    updateJoiningChannels(new Set(Array.from(joiningChannels)));
-    roomActions.join(roomIdOrAlias, false);
-  }
-
-  function renderChannelList(channels) {
-    return channels.map((channel) => {
-      const alias = typeof channel.canonical_alias === 'string' ? channel.canonical_alias : channel.room_id;
-      const name = typeof channel.name === 'string' ? channel.name : alias;
-      const isJoined = initMatrix.roomList.rooms.has(channel.room_id);
-      return (
-        <ChannelTile
-          key={channel.room_id}
-          avatarSrc={typeof channel.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(channel.avatar_url, 42, 42, 'crop') : null}
-          name={name}
-          id={alias}
-          memberCount={channel.num_joined_members}
-          desc={typeof channel.topic === 'string' ? channel.topic : null}
-          options={(
-            <>
-              {isJoined && <Button onClick={() => handleViewChannel(channel.room_id)}>Open</Button>}
-              {!isJoined && (joiningChannels.has(channel.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinChannel(channel.aliases?.[0] || channel.room_id)} variant="primary">Join</Button>)}
-            </>
-          )}
-        />
-      );
-    });
-  }
-
-  return (
-    <PopupWindow
-      isOpen={isOpen}
-      title="Public channels"
-      contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
-      onRequestClose={onRequestClose}
-    >
-      <div className="public-channels">
-        <form className="public-channels__form" onSubmit={(e) => { e.preventDefault(); searchChannels(); }}>
-          <div className="public-channels__input-wrapper">
-            <Input value={searchTerm} forwardRef={channelNameRef} label="Channel name or alias" />
-            <Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
-          </div>
-          <Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
-        </form>
-        <div className="public-channels__search-status">
-          {
-            typeof searchQuery.name !== 'undefined' && isSearching && (
-              searchQuery.name === ''
-                ? (
-                  <div className="flex--center">
-                    <Spinner size="small" />
-                    <Text variant="b2">{`Loading public channels from ${searchQuery.homeserver}...`}</Text>
-                  </div>
-                )
-                : (
-                  <div className="flex--center">
-                    <Spinner size="small" />
-                    <Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
-                  </div>
-                )
-            )
-          }
-          {
-            typeof searchQuery.name !== 'undefined' && !isSearching && (
-              searchQuery.name === ''
-                ? <Text variant="b2">{`Public channels on ${searchQuery.homeserver}.`}</Text>
-                : <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
-            )
-          }
-          { searchQuery.error && (
-            <>
-              <Text className="public-channels__search-error" variant="b2">{searchQuery.error}</Text>
-              {typeof searchQuery.alias === 'string' && (
-                <TryJoinWithAlias onRequestClose={onRequestClose} alias={searchQuery.alias} />
-              )}
-            </>
-          )}
-        </div>
-        { publicChannels.length !== 0 && (
-          <div className="public-channels__content">
-            { renderChannelList(publicChannels) }
-          </div>
-        )}
-        { publicChannels.length !== 0 && publicChannels.length % SEARCH_LIMIT === 0 && (
-          <div className="public-channels__view-more">
-            { isViewMore !== true && (
-              <Button onClick={() => searchChannels(true)}>View more</Button>
-            )}
-            { isViewMore && <Spinner /> }
-          </div>
-        )}
-      </div>
-    </PopupWindow>
-  );
-}
-
-PublicChannels.defaultProps = {
-  searchTerm: undefined,
-};
-
-PublicChannels.propTypes = {
-  isOpen: PropTypes.bool.isRequired,
-  searchTerm: PropTypes.string,
-  onRequestClose: PropTypes.func.isRequired,
-};
-
-export default PublicChannels;
diff --git a/src/app/organisms/public-channels/PublicChannels.scss b/src/app/organisms/public-channels/PublicChannels.scss
deleted file mode 100644 (file)
index 3eef310..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-.public-channels {
-  margin: 0 var(--sp-normal);
-  margin-right: var(--sp-extra-tight);
-  margin-top: var(--sp-extra-tight);
-
-  &__form {
-    display: flex;
-    align-items: flex-end;
-
-    & .btn-primary {
-      padding: {
-        top: 11px;
-        bottom: 11px;
-      }
-    }
-  }
-  &__input-wrapper {
-    flex: 1;
-    min-width: 0;
-
-    display: flex;
-    margin-right: var(--sp-normal);
-    [dir=rtl] & {
-      margin-right: 0;
-      margin-left: var(--sp-normal);
-    }
-
-    & > div:first-child {
-      flex: 1;
-      min-width: 0;
-
-      & .input {
-        border-radius: var(--bo-radius) 0 0 var(--bo-radius);
-        [dir=rtl] & {
-          border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
-        }
-      }
-    }
-
-    & > div:last-child .input {
-      width: 120px;
-      border-left-width: 0;
-      border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
-      [dir=rtl] & {
-        border-left-width: 1px;
-        border-right-width: 0;
-        border-radius: var(--bo-radius) 0 0 var(--bo-radius);
-      }
-    }
-  }
-
-  &__search-status {
-    margin-top: var(--sp-extra-loose);
-    margin-bottom: var(--sp-tight);
-    & .donut-spinner {
-      margin: 0 var(--sp-tight);
-    }
-
-    .try-join-with-alias {
-      margin-top: var(--sp-normal);
-    }
-  }
-  &__search-error {
-    color: var(--bg-danger);
-  }
-  &__content {
-    border-top: 1px solid var(--bg-surface-border);
-  }
-  &__view-more {
-    margin-top: var(--sp-loose);
-    margin-left: calc(var(--av-normal) + var(--sp-normal));
-    [dir=rtl] & {
-      margin-left: 0;
-      margin-right: calc(var(--av-normal) + var(--sp-normal));
-    }
-  }
-  
-  & .channel-tile {
-    margin-top: var(--sp-normal);
-    &__options {
-      align-self: flex-end;
-    }
-  }
-
-  [dir=rtl] & {
-    margin: {
-      left: var(--sp-extra-tight);
-      right: var(--sp-normal);
-    }
-  }
-}
-
-.try-join-with-alias {
-  display: flex;
-  align-items: center;
-  
-  & >.text:nth-child(2) {
-    margin: 0 var(--sp-normal);
-  }
-}
\ No newline at end of file
diff --git a/src/app/organisms/public-rooms/PublicRooms.jsx b/src/app/organisms/public-rooms/PublicRooms.jsx
new file mode 100644 (file)
index 0000000..b8f9244
--- /dev/null
@@ -0,0 +1,287 @@
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './PublicRooms.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { selectRoom } from '../../../client/action/navigation';
+import * as roomActions from '../../../client/action/room';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import IconButton from '../../atoms/button/IconButton';
+import Spinner from '../../atoms/spinner/Spinner';
+import Input from '../../atoms/input/Input';
+import PopupWindow from '../../molecules/popup-window/PopupWindow';
+import RoomTile from '../../molecules/room-tile/RoomTile';
+
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
+
+const SEARCH_LIMIT = 20;
+
+function TryJoinWithAlias({ alias, onRequestClose }) {
+  const [status, setStatus] = useState({
+    isJoining: false,
+    error: null,
+    roomId: null,
+    tempRoomId: null,
+  });
+  function handleOnRoomAdded(roomId) {
+    if (status.tempRoomId !== null && status.tempRoomId !== roomId) return;
+    setStatus({
+      isJoining: false, error: null, roomId, tempRoomId: null,
+    });
+  }
+
+  useEffect(() => {
+    initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+    return () => {
+      initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+    };
+  }, [status]);
+
+  async function joinWithAlias() {
+    setStatus({
+      isJoining: true, error: null, roomId: null, tempRoomId: null,
+    });
+    try {
+      const roomId = await roomActions.join(alias, false);
+      setStatus({
+        isJoining: true, error: null, roomId: null, tempRoomId: roomId,
+      });
+    } catch (e) {
+      setStatus({
+        isJoining: false,
+        error: `Unable to join ${alias}. Either room is private or doesn't exist.`,
+        roomId: null,
+        tempRoomId: null,
+      });
+    }
+  }
+
+  return (
+    <div className="try-join-with-alias">
+      {status.roomId === null && !status.isJoining && status.error === null && (
+        <Button onClick={() => joinWithAlias()}>{`Try joining ${alias}`}</Button>
+      )}
+      {status.isJoining && (
+        <>
+          <Spinner size="small" />
+          <Text>{`Joining ${alias}...`}</Text>
+        </>
+      )}
+      {status.roomId !== null && (
+        <Button onClick={() => { onRequestClose(); selectRoom(status.roomId); }}>Open</Button>
+      )}
+      {status.error !== null && <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{status.error}</span></Text>}
+    </div>
+  );
+}
+
+TryJoinWithAlias.propTypes = {
+  alias: PropTypes.string.isRequired,
+  onRequestClose: PropTypes.func.isRequired,
+};
+
+function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
+  const [isSearching, updateIsSearching] = useState(false);
+  const [isViewMore, updateIsViewMore] = useState(false);
+  const [publicRooms, updatePublicRooms] = useState([]);
+  const [nextBatch, updateNextBatch] = useState(undefined);
+  const [searchQuery, updateSearchQuery] = useState({});
+  const [joiningRooms, updateJoiningRooms] = useState(new Set());
+
+  const roomNameRef = useRef(null);
+  const hsRef = useRef(null);
+  const userId = initMatrix.matrixClient.getUserId();
+
+  async function searchRooms(viewMore) {
+    let inputRoomName = roomNameRef?.current?.value || searchTerm;
+    let isInputAlias = false;
+    if (typeof inputRoomName === 'string') {
+      isInputAlias = inputRoomName[0] === '#' && inputRoomName.indexOf(':') > 1;
+    }
+    const hsFromAlias = (isInputAlias) ? inputRoomName.slice(inputRoomName.indexOf(':') + 1) : null;
+    let inputHs = hsFromAlias || hsRef?.current?.value;
+
+    if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1);
+    if (typeof inputRoomName !== 'string') inputRoomName = '';
+
+    if (isSearching) return;
+    if (viewMore !== true
+      && inputRoomName === searchQuery.name
+      && inputHs === searchQuery.homeserver
+    ) return;
+
+    updateSearchQuery({
+      name: inputRoomName,
+      homeserver: inputHs,
+    });
+    if (isViewMore !== viewMore) updateIsViewMore(viewMore);
+    updateIsSearching(true);
+
+    try {
+      const result = await initMatrix.matrixClient.publicRooms({
+        server: inputHs,
+        limit: SEARCH_LIMIT,
+        since: viewMore ? nextBatch : undefined,
+        include_all_networks: true,
+        filter: {
+          generic_search_term: inputRoomName,
+        },
+      });
+
+      const totalRooms = viewMore ? publicRooms.concat(result.chunk) : result.chunk;
+      updatePublicRooms(totalRooms);
+      updateNextBatch(result.next_batch);
+      updateIsSearching(false);
+      updateIsViewMore(false);
+      if (totalRooms.length === 0) {
+        updateSearchQuery({
+          error: `No result found for "${inputRoomName}" on ${inputHs}`,
+          alias: isInputAlias ? inputRoomName : null,
+        });
+      }
+    } catch (e) {
+      updatePublicRooms([]);
+      updateSearchQuery({
+        error: 'Something went wrong!',
+        alias: isInputAlias ? inputRoomName : null,
+      });
+      updateIsSearching(false);
+      updateNextBatch(undefined);
+      updateIsViewMore(false);
+    }
+  }
+
+  useEffect(() => {
+    if (isOpen) searchRooms();
+  }, [isOpen]);
+
+  function handleOnRoomAdded(roomId) {
+    if (joiningRooms.has(roomId)) {
+      joiningRooms.delete(roomId);
+      updateJoiningRooms(new Set(Array.from(joiningRooms)));
+    }
+  }
+  useEffect(() => {
+    initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+    return () => {
+      initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+    };
+  }, [joiningRooms]);
+
+  function handleViewRoom(roomId) {
+    selectRoom(roomId);
+    onRequestClose();
+  }
+
+  function joinRoom(roomIdOrAlias) {
+    joiningRooms.add(roomIdOrAlias);
+    updateJoiningRooms(new Set(Array.from(joiningRooms)));
+    roomActions.join(roomIdOrAlias, false);
+  }
+
+  function renderRoomList(rooms) {
+    return rooms.map((room) => {
+      const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
+      const name = typeof room.name === 'string' ? room.name : alias;
+      const isJoined = initMatrix.roomList.rooms.has(room.room_id);
+      return (
+        <RoomTile
+          key={room.room_id}
+          avatarSrc={typeof room.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(room.avatar_url, 42, 42, 'crop') : null}
+          name={name}
+          id={alias}
+          memberCount={room.num_joined_members}
+          desc={typeof room.topic === 'string' ? room.topic : null}
+          options={(
+            <>
+              {isJoined && <Button onClick={() => handleViewRoom(room.room_id)}>Open</Button>}
+              {!isJoined && (joiningRooms.has(room.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinRoom(room.aliases?.[0] || room.room_id)} variant="primary">Join</Button>)}
+            </>
+          )}
+        />
+      );
+    });
+  }
+
+  return (
+    <PopupWindow
+      isOpen={isOpen}
+      title="Public rooms"
+      contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+      onRequestClose={onRequestClose}
+    >
+      <div className="public-rooms">
+        <form className="public-rooms__form" onSubmit={(e) => { e.preventDefault(); searchRooms(); }}>
+          <div className="public-rooms__input-wrapper">
+            <Input value={searchTerm} forwardRef={roomNameRef} label="Room name or alias" />
+            <Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
+          </div>
+          <Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
+        </form>
+        <div className="public-rooms__search-status">
+          {
+            typeof searchQuery.name !== 'undefined' && isSearching && (
+              searchQuery.name === ''
+                ? (
+                  <div className="flex--center">
+                    <Spinner size="small" />
+                    <Text variant="b2">{`Loading public rooms from ${searchQuery.homeserver}...`}</Text>
+                  </div>
+                )
+                : (
+                  <div className="flex--center">
+                    <Spinner size="small" />
+                    <Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
+                  </div>
+                )
+            )
+          }
+          {
+            typeof searchQuery.name !== 'undefined' && !isSearching && (
+              searchQuery.name === ''
+                ? <Text variant="b2">{`Public rooms on ${searchQuery.homeserver}.`}</Text>
+                : <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
+            )
+          }
+          { searchQuery.error && (
+            <>
+              <Text className="public-rooms__search-error" variant="b2">{searchQuery.error}</Text>
+              {typeof searchQuery.alias === 'string' && (
+                <TryJoinWithAlias onRequestClose={onRequestClose} alias={searchQuery.alias} />
+              )}
+            </>
+          )}
+        </div>
+        { publicRooms.length !== 0 && (
+          <div className="public-rooms__content">
+            { renderRoomList(publicRooms) }
+          </div>
+        )}
+        { publicRooms.length !== 0 && publicRooms.length % SEARCH_LIMIT === 0 && (
+          <div className="public-rooms__view-more">
+            { isViewMore !== true && (
+              <Button onClick={() => searchRooms(true)}>View more</Button>
+            )}
+            { isViewMore && <Spinner /> }
+          </div>
+        )}
+      </div>
+    </PopupWindow>
+  );
+}
+
+PublicRooms.defaultProps = {
+  searchTerm: undefined,
+};
+
+PublicRooms.propTypes = {
+  isOpen: PropTypes.bool.isRequired,
+  searchTerm: PropTypes.string,
+  onRequestClose: PropTypes.func.isRequired,
+};
+
+export default PublicRooms;
diff --git a/src/app/organisms/public-rooms/PublicRooms.scss b/src/app/organisms/public-rooms/PublicRooms.scss
new file mode 100644 (file)
index 0000000..66b77c0
--- /dev/null
@@ -0,0 +1,100 @@
+.public-rooms {
+  margin: 0 var(--sp-normal);
+  margin-right: var(--sp-extra-tight);
+  margin-top: var(--sp-extra-tight);
+
+  &__form {
+    display: flex;
+    align-items: flex-end;
+
+    & .btn-primary {
+      padding: {
+        top: 11px;
+        bottom: 11px;
+      }
+    }
+  }
+  &__input-wrapper {
+    flex: 1;
+    min-width: 0;
+
+    display: flex;
+    margin-right: var(--sp-normal);
+    [dir=rtl] & {
+      margin-right: 0;
+      margin-left: var(--sp-normal);
+    }
+
+    & > div:first-child {
+      flex: 1;
+      min-width: 0;
+
+      & .input {
+        border-radius: var(--bo-radius) 0 0 var(--bo-radius);
+        [dir=rtl] & {
+          border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
+        }
+      }
+    }
+
+    & > div:last-child .input {
+      width: 120px;
+      border-left-width: 0;
+      border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
+      [dir=rtl] & {
+        border-left-width: 1px;
+        border-right-width: 0;
+        border-radius: var(--bo-radius) 0 0 var(--bo-radius);
+      }
+    }
+  }
+
+  &__search-status {
+    margin-top: var(--sp-extra-loose);
+    margin-bottom: var(--sp-tight);
+    & .donut-spinner {
+      margin: 0 var(--sp-tight);
+    }
+
+    .try-join-with-alias {
+      margin-top: var(--sp-normal);
+    }
+  }
+  &__search-error {
+    color: var(--bg-danger);
+  }
+  &__content {
+    border-top: 1px solid var(--bg-surface-border);
+  }
+  &__view-more {
+    margin-top: var(--sp-loose);
+    margin-left: calc(var(--av-normal) + var(--sp-normal));
+    [dir=rtl] & {
+      margin-left: 0;
+      margin-right: calc(var(--av-normal) + var(--sp-normal));
+    }
+  }
+  
+  & .room-tile {
+    margin-top: var(--sp-normal);
+    &__options {
+      align-self: flex-end;
+    }
+  }
+
+  [dir=rtl] & {
+    margin: {
+      left: var(--sp-extra-tight);
+      right: var(--sp-normal);
+    }
+  }
+}
+
+.try-join-with-alias {
+  display: flex;
+  align-items: center;
+  
+  & >.text:nth-child(2) {
+    margin: 0 var(--sp-normal);
+  }
+}
\ No newline at end of file
index 8a0afd3d3679f2c93559f61681d94269bb9f5521..32a0ee1b09b040b5788641b9d2aea629d154a85d 100644 (file)
@@ -4,17 +4,17 @@ import cons from '../../../client/state/cons';
 import navigation from '../../../client/state/navigation';
 
 import InviteList from '../invite-list/InviteList';
-import PublicChannels from '../public-channels/PublicChannels';
-import CreateChannel from '../create-channel/CreateChannel';
+import PublicRooms from '../public-rooms/PublicRooms';
+import CreateRoom from '../create-room/CreateRoom';
 import InviteUser from '../invite-user/InviteUser';
 import Settings from '../settings/Settings';
 
 function Windows() {
   const [isInviteList, changeInviteList] = useState(false);
-  const [publicChannels, changePublicChannels] = useState({
+  const [publicRooms, changePublicRooms] = useState({
     isOpen: false, searchTerm: undefined,
   });
-  const [isCreateChannel, changeCreateChannel] = useState(false);
+  const [isCreateRoom, changeCreateRoom] = useState(false);
   const [inviteUser, changeInviteUser] = useState({
     isOpen: false, roomId: undefined, term: undefined,
   });
@@ -23,14 +23,14 @@ function Windows() {
   function openInviteList() {
     changeInviteList(true);
   }
-  function openPublicChannels(searchTerm) {
-    changePublicChannels({
+  function openPublicRooms(searchTerm) {
+    changePublicRooms({
       isOpen: true,
       searchTerm,
     });
   }
-  function openCreateChannel() {
-    changeCreateChannel(true);
+  function openCreateRoom() {
+    changeCreateRoom(true);
   }
   function openInviteUser(roomId, searchTerm) {
     changeInviteUser({
@@ -45,14 +45,14 @@ function Windows() {
 
   useEffect(() => {
     navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
-    navigation.on(cons.events.navigation.PUBLIC_CHANNELS_OPENED, openPublicChannels);
-    navigation.on(cons.events.navigation.CREATE_CHANNEL_OPENED, openCreateChannel);
+    navigation.on(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
+    navigation.on(cons.events.navigation.CREATE_ROOM_OPENED, openCreateRoom);
     navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
     navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
     return () => {
       navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
-      navigation.removeListener(cons.events.navigation.PUBLIC_CHANNELS_OPENED, openPublicChannels);
-      navigation.removeListener(cons.events.navigation.CREATE_CHANNEL_OPENED, openCreateChannel);
+      navigation.removeListener(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
+      navigation.removeListener(cons.events.navigation.CREATE_ROOM_OPENED, openCreateRoom);
       navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
       navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
     };
@@ -64,14 +64,14 @@ function Windows() {
         isOpen={isInviteList}
         onRequestClose={() => changeInviteList(false)}
       />
-      <PublicChannels
-        isOpen={publicChannels.isOpen}
-        searchTerm={publicChannels.searchTerm}
-        onRequestClose={() => changePublicChannels({ isOpen: false, searchTerm: undefined })}
+      <PublicRooms
+        isOpen={publicRooms.isOpen}
+        searchTerm={publicRooms.searchTerm}
+        onRequestClose={() => changePublicRooms({ isOpen: false, searchTerm: undefined })}
       />
-      <CreateChannel
-        isOpen={isCreateChannel}
-        onRequestClose={() => changeCreateChannel(false)}
+      <CreateRoom
+        isOpen={isCreateRoom}
+        onRequestClose={() => changeCreateRoom(false)}
       />
       <InviteUser
         isOpen={inviteUser.isOpen}
diff --git a/src/app/organisms/room/PeopleDrawer.jsx b/src/app/organisms/room/PeopleDrawer.jsx
new file mode 100644 (file)
index 0000000..2a7b18d
--- /dev/null
@@ -0,0 +1,138 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './PeopleDrawer.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import { getUsernameOfRoomMember } from '../../../util/matrixUtil';
+import colorMXID from '../../../util/colorMXID';
+import { openInviteUser } from '../../../client/action/navigation';
+
+import Text from '../../atoms/text/Text';
+import Header, { TitleWrapper } from '../../atoms/header/Header';
+import IconButton from '../../atoms/button/IconButton';
+import Button from '../../atoms/button/Button';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import Input from '../../atoms/input/Input';
+import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
+
+import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
+
+function getPowerLabel(powerLevel) {
+  switch (powerLevel) {
+    case 100:
+      return 'Admin';
+    case 50:
+      return 'Mod';
+    default:
+      return null;
+  }
+}
+function compare(m1, m2) {
+  let aName = m1.name;
+  let bName = m2.name;
+
+  // remove "#" from the room name
+  // To ignore it in sorting
+  aName = aName.replaceAll('#', '');
+  bName = bName.replaceAll('#', '');
+
+  if (aName.toLowerCase() < bName.toLowerCase()) {
+    return -1;
+  }
+  if (aName.toLowerCase() > bName.toLowerCase()) {
+    return 1;
+  }
+  return 0;
+}
+function sortByPowerLevel(m1, m2) {
+  let pl1 = String(m1.powerLevel);
+  let pl2 = String(m2.powerLevel);
+
+  if (pl1 === '100') pl1 = '90.9';
+  if (pl2 === '100') pl2 = '90.9';
+
+  if (pl1.toLowerCase() > pl2.toLowerCase()) {
+    return -1;
+  }
+  if (pl1.toLowerCase() < pl2.toLowerCase()) {
+    return 1;
+  }
+  return 0;
+}
+
+function PeopleDrawer({ roomId }) {
+  const PER_PAGE_MEMBER = 50;
+  const room = initMatrix.matrixClient.getRoom(roomId);
+  const totalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
+  const [memberList, updateMemberList] = useState([]);
+  let isRoomChanged = false;
+
+  function loadMorePeople() {
+    updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER));
+  }
+
+  useEffect(() => {
+    updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER));
+    room.loadMembersIfNeeded().then(() => {
+      if (isRoomChanged) return;
+      const newTotalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
+      updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER));
+    });
+
+    return () => {
+      isRoomChanged = true;
+    };
+  }, [roomId]);
+
+  return (
+    <div className="people-drawer">
+      <Header>
+        <TitleWrapper>
+          <Text variant="s1">
+            People
+            <Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
+          </Text>
+        </TitleWrapper>
+        <IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} />
+      </Header>
+      <div className="people-drawer__content-wrapper">
+        <div className="people-drawer__scrollable">
+          <ScrollView autoHide>
+            <div className="people-drawer__content">
+              {
+                memberList.map((member) => (
+                  <PeopleSelector
+                    key={member.userId}
+                    onClick={() => alert('Viewing profile is yet to be implemented')}
+                    avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
+                    name={getUsernameOfRoomMember(member)}
+                    color={colorMXID(member.userId)}
+                    peopleRole={getPowerLabel(member.powerLevel)}
+                  />
+                ))
+              }
+              <div className="people-drawer__load-more">
+                {
+                  memberList.length !== totalMemberList.length && (
+                    <Button onClick={loadMorePeople}>View more</Button>
+                  )
+                }
+              </div>
+            </div>
+          </ScrollView>
+        </div>
+        <div className="people-drawer__sticky">
+          <form onSubmit={(e) => e.preventDefault()} className="people-search">
+            <Input type="text" placeholder="Search" required />
+          </form>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+PeopleDrawer.propTypes = {
+  roomId: PropTypes.string.isRequired,
+};
+
+export default PeopleDrawer;
diff --git a/src/app/organisms/room/PeopleDrawer.scss b/src/app/organisms/room/PeopleDrawer.scss
new file mode 100644 (file)
index 0000000..56ac29e
--- /dev/null
@@ -0,0 +1,75 @@
+.people-drawer-flexBox {
+  display: flex;
+  flex-direction: column;
+}
+.people-drawer-flexItem {
+  flex: 1;
+  min-height: 0;
+  min-width: 0;
+}
+
+
+.people-drawer {
+  @extend .people-drawer-flexBox;
+  width: var(--people-drawer-width);
+  background-color: var(--bg-surface-low);
+  border-left: 1px solid var(--bg-surface-border);
+
+  [dir=rtl] & {
+    border: {
+      left: none;
+      right: 1px solid var(--bg-surface-hover);
+    }
+  }
+
+  &__member-count {
+    color: var(--tc-surface-low);
+  }
+
+  &__content-wrapper {
+    @extend .people-drawer-flexItem;
+    @extend .people-drawer-flexBox;
+  }
+
+  &__scrollable {
+    @extend .people-drawer-flexItem;
+  }
+
+  &__sticky {
+    display: none;
+
+    & .people-search {
+      min-height: 48px;
+  
+      margin: 0 var(--sp-normal);
+
+      position: relative;
+      bottom: var(--sp-normal);
+
+      & .input {
+        height: 48px;
+      }
+    }
+  }
+}
+
+.people-drawer__content {
+  padding-top: var(--sp-extra-tight);
+  padding-bottom: calc( var(--sp-extra-tight) + var(--sp-normal));
+}
+.people-drawer__load-more {
+  padding: var(--sp-normal);
+  padding: {
+    bottom: 0;
+    right: var(--sp-extra-tight);
+  }
+
+  [dir=rtl] & {
+    padding-right: var(--sp-normal);
+    padding-left: var(--sp-extra-tight);
+  }
+
+  & .btn-surface {
+    width: 100%;
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/room/Room.jsx b/src/app/organisms/room/Room.jsx
new file mode 100644 (file)
index 0000000..6112d2b
--- /dev/null
@@ -0,0 +1,40 @@
+import React, { useState, useEffect } from 'react';
+import './Room.scss';
+
+import cons from '../../../client/state/cons';
+import navigation from '../../../client/state/navigation';
+
+import Welcome from '../welcome/Welcome';
+import RoomView from './RoomView';
+import PeopleDrawer from './PeopleDrawer';
+
+function Room() {
+  const [selectedRoomId, changeSelectedRoomId] = useState(null);
+  const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible);
+  useEffect(() => {
+    const handleRoomSelected = (roomId) => {
+      changeSelectedRoomId(roomId);
+    };
+    const handleDrawerToggling = (visiblity) => {
+      toggleDrawerVisiblity(visiblity);
+    };
+    navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+    navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
+
+    return () => {
+      navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+      navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
+    };
+  }, []);
+
+  if (selectedRoomId === null) return <Welcome />;
+
+  return (
+    <div className="room-container">
+      <RoomView roomId={selectedRoomId} />
+      { isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
+    </div>
+  );
+}
+
+export default Room;
diff --git a/src/app/organisms/room/Room.scss b/src/app/organisms/room/Room.scss
new file mode 100644 (file)
index 0000000..cea4bad
--- /dev/null
@@ -0,0 +1,4 @@
+.room-container {
+  display: flex;
+  height: 100%;
+}
\ No newline at end of file
diff --git a/src/app/organisms/room/RoomView.jsx b/src/app/organisms/room/RoomView.jsx
new file mode 100644 (file)
index 0000000..edb427d
--- /dev/null
@@ -0,0 +1,150 @@
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './RoomView.scss';
+
+import EventEmitter from 'events';
+
+import RoomTimeline from '../../../client/state/RoomTimeline';
+
+import ScrollView from '../../atoms/scroll/ScrollView';
+
+import RoomViewHeader from './RoomViewHeader';
+import RoomViewContent from './RoomViewContent';
+import RoomViewFloating from './RoomViewFloating';
+import RoomViewInput from './RoomViewInput';
+import RoomViewCmdBar from './RoomViewCmdBar';
+
+import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
+
+const viewEvent = new EventEmitter();
+
+let lastScrollTop = 0;
+let lastScrollHeight = 0;
+let isReachedBottom = true;
+let isReachedTop = false;
+function RoomView({ roomId }) {
+  const [roomTimeline, updateRoomTimeline] = useState(null);
+  const timelineSVRef = useRef(null);
+
+  useEffect(() => {
+    roomTimeline?.removeInternalListeners();
+    updateRoomTimeline(new RoomTimeline(roomId));
+    isReachedBottom = true;
+    isReachedTop = false;
+  }, [roomId]);
+
+  const timelineScroll = {
+    reachBottom() {
+      scrollToBottom(timelineSVRef);
+    },
+    autoReachBottom() {
+      autoScrollToBottom(timelineSVRef);
+    },
+    tryRestoringScroll() {
+      const sv = timelineSVRef.current;
+      const { scrollHeight } = sv;
+
+      if (lastScrollHeight === scrollHeight) return;
+
+      if (lastScrollHeight < scrollHeight) {
+        sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
+      } else {
+        timelineScroll.reachBottom();
+      }
+    },
+    enableSmoothScroll() {
+      timelineSVRef.current.style.scrollBehavior = 'smooth';
+    },
+    disableSmoothScroll() {
+      timelineSVRef.current.style.scrollBehavior = 'auto';
+    },
+    isScrollable() {
+      const oHeight = timelineSVRef.current.offsetHeight;
+      const sHeight = timelineSVRef.current.scrollHeight;
+      if (sHeight > oHeight) return true;
+      return false;
+    },
+  };
+
+  function onTimelineScroll(e) {
+    const { scrollTop, scrollHeight, offsetHeight } = e.target;
+    const scrollBottom = scrollTop + offsetHeight;
+    lastScrollTop = scrollTop;
+    lastScrollHeight = scrollHeight;
+
+    const PLACEHOLDER_HEIGHT = 96;
+    const PLACEHOLDER_COUNT = 3;
+
+    const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT;
+    const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2);
+
+    if (!isReachedBottom && isAtBottom(timelineSVRef)) {
+      isReachedBottom = true;
+      viewEvent.emit('toggle-reached-bottom', true);
+    }
+    if (isReachedBottom && !isAtBottom(timelineSVRef)) {
+      isReachedBottom = false;
+      viewEvent.emit('toggle-reached-bottom', false);
+    }
+    // TOP of timeline
+    if (scrollTop < topPagKeyPoint && isReachedTop === false) {
+      isReachedTop = true;
+      viewEvent.emit('reached-top');
+      return;
+    }
+    isReachedTop = false;
+
+    // BOTTOM of timeline
+    if (scrollBottom > bottomPagKeyPoint) {
+      // TODO:
+    }
+  }
+
+  return (
+    <div className="room-view">
+      <RoomViewHeader roomId={roomId} />
+      <div className="room-view__content-wrapper">
+        <div className="room-view__scrollable">
+          <ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide>
+            {roomTimeline !== null && (
+              <RoomViewContent
+                roomId={roomId}
+                roomTimeline={roomTimeline}
+                timelineScroll={timelineScroll}
+                viewEvent={viewEvent}
+              />
+            )}
+          </ScrollView>
+          {roomTimeline !== null && (
+            <RoomViewFloating
+              roomId={roomId}
+              roomTimeline={roomTimeline}
+              timelineScroll={timelineScroll}
+              viewEvent={viewEvent}
+            />
+          )}
+        </div>
+        {roomTimeline !== null && (
+          <div className="room-view__sticky">
+            <RoomViewInput
+              roomId={roomId}
+              roomTimeline={roomTimeline}
+              timelineScroll={timelineScroll}
+              viewEvent={viewEvent}
+            />
+            <RoomViewCmdBar
+              roomId={roomId}
+              roomTimeline={roomTimeline}
+              viewEvent={viewEvent}
+            />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+RoomView.propTypes = {
+  roomId: PropTypes.string.isRequired,
+};
+
+export default RoomView;
diff --git a/src/app/organisms/room/RoomView.scss b/src/app/organisms/room/RoomView.scss
new file mode 100644 (file)
index 0000000..dd7e961
--- /dev/null
@@ -0,0 +1,31 @@
+.room-view-flexBox {
+  display: flex;
+  flex-direction: column;
+}
+.room-view-flexItem {
+  flex: 1;
+  min-height: 0;
+  min-width: 0;
+}
+
+.room-view {
+  @extend .room-view-flexItem;
+  @extend .room-view-flexBox;
+
+  &__content-wrapper {
+    @extend .room-view-flexItem;
+    @extend .room-view-flexBox;
+  }
+
+  &__scrollable {
+    @extend .room-view-flexItem;
+    position: relative;
+  }
+  
+  &__sticky {
+    min-height: 85px;
+    position: relative;
+    background: var(--bg-surface);
+    border-top: 1px solid var(--bg-surface-border);
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/room/RoomViewCmdBar.jsx b/src/app/organisms/room/RoomViewCmdBar.jsx
new file mode 100644 (file)
index 0000000..7f8f809
--- /dev/null
@@ -0,0 +1,475 @@
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewCmdBar.scss';
+import parse from 'html-react-parser';
+import twemoji from 'twemoji';
+
+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,
+  openCreateRoom,
+  openPublicRooms,
+  openInviteUser,
+  openReadReceipts,
+} from '../../../client/action/navigation';
+import { emojis } from '../emoji-board/emoji';
+import AsyncSearch from '../../../util/AsyncSearch';
+
+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';
+
+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: 'createRoom',
+  description: 'Create new room',
+  exe: () => openCreateRoom(),
+}, {
+  name: 'join',
+  isOptions: true,
+  description: 'Join room with alias. Example: /join/#cinny:matrix.org',
+  exe: (roomId, searchTerm) => openPublicRooms(searchTerm),
+}, {
+  name: 'leave',
+  description: 'Leave current room',
+  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">{'>#room_name'}</Text>
+          <Text variant="b2">{'>@people_name'}</Text>
+          <MenuHeader>Autofill commands</MenuHeader>
+          <Text variant="b2">:emoji_name</Text>
+          <Text variant="b2">@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;
+
+  function handleOnMessageSent() {
+    setFollowingMembers([]);
+  }
+
+  function updateFollowingMembers() {
+    const room = mx.getRoom(roomId);
+    const { timeline } = room;
+    const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
+    const myUserId = mx.getUserId();
+    setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
+  }
+
+  useEffect(() => updateFollowingMembers(), [roomId]);
+
+  useEffect(() => {
+    roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+    viewEvent.on('message_sent', handleOnMessageSent);
+    return () => {
+      roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+      viewEvent.removeListener('message_sent', handleOnMessageSent);
+    };
+  }, [roomTimeline]);
+
+  const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1];
+  return followingMembers.length !== 0 && (
+    <TimelineChange
+      variant="follow"
+      content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')}
+      time=""
+      onClick={() => openReadReceipts(roomId, lastMEvent.getId())}
+    />
+  );
+}
+
+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 room 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.'),
+    '@': () => genMessage('Name autofill command mode activated. ', 'Type name for suggestions.'),
+  };
+  return cmd[prefix]?.();
+}
+
+function CmdItem({ onClick, children }) {
+  return (
+    <button className="cmd-item" onClick={onClick} type="button">
+      {children}
+    </button>
+  );
+}
+CmdItem.propTypes = {
+  onClick: PropTypes.func.isRequired,
+  children: PropTypes.node.isRequired,
+};
+
+function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
+  function getGenCmdSuggestions(cmdPrefix, cmds) {
+    const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?';
+    return cmds.map((cmd) => (
+      <CmdItem
+        key={cmd.name}
+        onClick={() => {
+          fireCmd({
+            prefix: cmdPrefix,
+            option,
+            result: cmd,
+          });
+        }}
+      >
+        <Text variant="b2">{`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`}</Text>
+      </CmdItem>
+    ));
+  }
+
+  function getRoomsSuggestion(cmdPrefix, rooms) {
+    return rooms.map((room) => (
+      <CmdItem
+        key={room.roomId}
+        onClick={() => {
+          fireCmd({
+            prefix: cmdPrefix,
+            result: room,
+          });
+        }}
+      >
+        <Text variant="b2">{room.name}</Text>
+      </CmdItem>
+    ));
+  }
+
+  function getEmojiSuggestion(emPrefix, emos) {
+    return emos.map((emoji) => (
+      <CmdItem
+        key={emoji.hexcode}
+        onClick={() => fireCmd({
+          prefix: emPrefix,
+          result: emoji,
+        })}
+      >
+        {
+          parse(twemoji.parse(
+            emoji.unicode,
+            {
+              attributes: () => ({
+                unicode: emoji.unicode,
+                shortcodes: emoji.shortcodes?.toString(),
+              }),
+            },
+          ))
+        }
+        <Text variant="b2">{`:${emoji.shortcode}:`}</Text>
+      </CmdItem>
+    ));
+  }
+
+  function getNameSuggestion(namePrefix, members) {
+    return members.map((member) => (
+      <CmdItem
+        key={member.userId}
+        onClick={() => {
+          fireCmd({
+            prefix: namePrefix,
+            result: member,
+          });
+        }}
+      >
+        <Text variant="b2">{member.name}</Text>
+      </CmdItem>
+    ));
+  }
+
+  const cmd = {
+    '/': (cmds) => getGenCmdSuggestions(prefix, cmds),
+    '>*': (spaces) => getRoomsSuggestion(prefix, spaces),
+    '>#': (rooms) => getRoomsSuggestion(prefix, rooms),
+    '>@': (peoples) => getRoomsSuggestion(prefix, peoples),
+    ':': (emos) => getEmojiSuggestion(prefix, emos),
+    '@': (members) => getNameSuggestion(prefix, members),
+  };
+  return cmd[prefix]?.(suggestions);
+}
+
+const asyncSearch = new AsyncSearch();
+let cmdPrefix;
+let cmdOption;
+function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
+  const [cmd, setCmd] = useState(null);
+
+  function displaySuggestions(suggestions) {
+    if (suggestions.length === 0) {
+      setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
+      viewEvent.emit('cmd_error');
+      return;
+    }
+    setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
+  }
+
+  function processCmd(prefix, slug) {
+    let searchTerm = slug;
+    cmdOption = undefined;
+    cmdPrefix = prefix;
+    if (prefix === '/') {
+      const cmdSlugParts = slug.split('/');
+      [searchTerm, cmdOption] = cmdSlugParts;
+    }
+    if (prefix === ':') {
+      if (searchTerm.length <= 3) {
+        if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
+        else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
+        else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
+        else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
+        else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
+        else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
+        else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
+        else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
+        else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
+        else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
+        else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
+        else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
+      }
+    }
+
+    asyncSearch.search(searchTerm);
+  }
+  function activateCmd(prefix) {
+    setCmd({ prefix });
+    cmdPrefix = prefix;
+
+    const { roomList, matrixClient } = initMatrix;
+    function getRooms(roomIds) {
+      return roomIds.map((rId) => {
+        const room = matrixClient.getRoom(rId);
+        return {
+          name: room.name,
+          roomId: room.roomId,
+        };
+      });
+    }
+    const setupSearch = {
+      '/': () => asyncSearch.setup(commands, { keys: ['name'], isContain: true }),
+      '>*': () => asyncSearch.setup(getRooms([...roomList.spaces]), { keys: ['name'], limit: 20 }),
+      '>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }),
+      '>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { keys: ['name'], limit: 20 }),
+      ':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 20 }),
+      '@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({
+        name: member.name,
+        userId: member.userId.slice(1),
+      })), { keys: ['name', 'userId'], limit: 20 }),
+    };
+    setupSearch[prefix]?.();
+  }
+  function deactivateCmd() {
+    setCmd(null);
+    cmdOption = undefined;
+    cmdPrefix = undefined;
+  }
+  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,
+      });
+    }
+    if (myCmd.prefix === '@') {
+      viewEvent.emit('cmd_fired', {
+        replace: myCmd.result.name,
+      });
+    }
+    deactivateCmd();
+  }
+  function executeCmd() {
+    if (cmd.suggestions.length === 0) return;
+    fireCmd({
+      prefix: cmd.prefix,
+      option: cmd.option,
+      result: cmd.suggestions[0],
+    });
+  }
+
+  function listenKeyboard(event) {
+    const { activeElement } = document;
+    const lastCmdItem = document.activeElement.parentNode.lastElementChild;
+    if (event.keyCode === 27) {
+      if (activeElement.className !== 'cmd-item') return;
+      viewEvent.emit('focus_msg_input');
+    }
+    if (event.keyCode === 9) {
+      if (lastCmdItem.className !== 'cmd-item') return;
+      if (lastCmdItem !== activeElement) return;
+      if (event.shiftKey) return;
+      viewEvent.emit('focus_msg_input');
+      event.preventDefault();
+    }
+  }
+
+  useEffect(() => {
+    viewEvent.on('cmd_activate', activateCmd);
+    viewEvent.on('cmd_deactivate', deactivateCmd);
+    return () => {
+      deactivateCmd();
+      viewEvent.removeListener('cmd_activate', activateCmd);
+      viewEvent.removeListener('cmd_deactivate', deactivateCmd);
+    };
+  }, [roomId]);
+
+  useEffect(() => {
+    if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
+    viewEvent.on('cmd_process', processCmd);
+    viewEvent.on('cmd_exe', executeCmd);
+    asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
+    return () => {
+      if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
+
+      viewEvent.removeListener('cmd_process', processCmd);
+      viewEvent.removeListener('cmd_exe', executeCmd);
+      asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
+    };
+  }, [cmd]);
+
+  if (typeof cmd?.error === 'string') {
+    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.suggestions === 'undefined' && <div className="cmd-bar__info-indicator" /> }
+        {cmd !== null && typeof cmd.suggestions !== 'undefined' && <Text variant="b3">TAB</Text>}
+      </div>
+      <div className="cmd-bar__content">
+        {cmd === null && (
+          <FollowingMembers
+            roomId={roomId}
+            roomTimeline={roomTimeline}
+            viewEvent={viewEvent}
+          />
+        )}
+        {cmd !== null && typeof cmd.suggestions === 'undefined' && <Text className="cmd-bar__content-help" variant="b2">{getCmdActivationMessage(cmd.prefix)}</Text>}
+        {cmd !== null && typeof cmd.suggestions !== 'undefined' && (
+          <ScrollView horizontal vertical={false} invisible>
+            <div className="cmd-bar__content__suggestions">{getCmdSuggestions(cmd, fireCmd)}</div>
+          </ScrollView>
+        )}
+      </div>
+      <div className="cmd-bar__more">
+        {cmd !== null && cmd.prefix === '/' && <ViewCmd />}
+      </div>
+    </div>
+  );
+}
+RoomViewCmdBar.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  roomTimeline: PropTypes.shape({}).isRequired,
+  viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewCmdBar;
diff --git a/src/app/organisms/room/RoomViewCmdBar.scss b/src/app/organisms/room/RoomViewCmdBar.scss
new file mode 100644 (file)
index 0000000..dc8a981
--- /dev/null
@@ -0,0 +1,144 @@
+.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 {
+        @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;
+
+  & .emoji {
+    width: 20px;
+    height: 20px;
+    margin-right: var(--sp-ultra-tight);
+  }
+
+  &:hover {
+    background-color: var(--bg-caution-hover);
+  }
+  &:focus {
+    background-color: var(--bg-caution-active);
+    box-shadow: var(--cmd-item-bar);
+    border-bottom: 2px solid transparent;
+    outline: none;
+  }
+  
+  [dir=rtl] & {
+    margin-right: 0;
+    margin-left: var(--sp-extra-tight);
+    & .emoji {
+      margin-right: 0;
+      margin-left: var(--sp-ultra-tight);
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/room/RoomViewContent.jsx b/src/app/organisms/room/RoomViewContent.jsx
new file mode 100644 (file)
index 0000000..18b8d34
--- /dev/null
@@ -0,0 +1,581 @@
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect, useLayoutEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewContent.scss';
+
+import dateFormat from 'dateformat';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
+import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil';
+import colorMXID from '../../../util/colorMXID';
+import { diffMinutes, isNotInSameDay } from '../../../util/common';
+import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation';
+
+import Divider from '../../atoms/divider/Divider';
+import Avatar from '../../atoms/avatar/Avatar';
+import IconButton from '../../atoms/button/IconButton';
+import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
+import {
+  Message,
+  MessageHeader,
+  MessageReply,
+  MessageContent,
+  MessageEdit,
+  MessageReactionGroup,
+  MessageReaction,
+  MessageOptions,
+  PlaceholderMessage,
+} from '../../molecules/message/Message';
+import * as Media from '../../molecules/media/Media';
+import RoomIntro from '../../molecules/room-intro/RoomIntro';
+import TimelineChange from '../../molecules/message/TimelineChange';
+
+import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
+import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
+import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
+import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
+import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
+import BinIC from '../../../../public/res/ic/outlined/bin.svg';
+
+import { parseReply, parseTimelineChange } from './common';
+
+const MAX_MSG_DIFF_MINUTES = 5;
+
+function genPlaceholders() {
+  return (
+    <>
+      <PlaceholderMessage key="placeholder-1" />
+      <PlaceholderMessage key="placeholder-2" />
+      <PlaceholderMessage key="placeholder-3" />
+    </>
+  );
+}
+
+function isMedia(mE) {
+  return (
+    mE.getContent()?.msgtype === 'm.file'
+    || mE.getContent()?.msgtype === 'm.image'
+    || mE.getContent()?.msgtype === 'm.audio'
+    || mE.getContent()?.msgtype === 'm.video'
+    || mE.getType() === 'm.sticker'
+  );
+}
+
+function genMediaContent(mE) {
+  const mx = initMatrix.matrixClient;
+  const mContent = mE.getContent();
+  if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
+
+  let mediaMXC = mContent?.url;
+  const isEncryptedFile = typeof mediaMXC === 'undefined';
+  if (isEncryptedFile) mediaMXC = mContent?.file?.url;
+
+  let thumbnailMXC = mContent?.info?.thumbnail_url;
+
+  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';
+
+  switch (msgType) {
+    case 'm.file':
+      return (
+        <Media.File
+          name={mContent.body}
+          link={mx.mxcUrlToHttp(mediaMXC)}
+          type={mContent.info?.mimetype}
+          file={mContent.file || null}
+        />
+      );
+    case 'm.image':
+      return (
+        <Media.Image
+          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
+          name={mContent.body}
+          link={mx.mxcUrlToHttp(mediaMXC)}
+          type={mContent.info?.mimetype}
+          file={mContent.file || null}
+        />
+      );
+    case 'm.video':
+      if (typeof thumbnailMXC === 'undefined') {
+        thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
+      }
+      return (
+        <Media.Video
+          name={mContent.body}
+          link={mx.mxcUrlToHttp(mediaMXC)}
+          thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
+          thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
+          thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
+          width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
+          height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
+          file={isEncryptedFile ? mContent.file : null}
+          type={mContent.info?.mimetype}
+        />
+      );
+    default:
+      return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
+  }
+}
+
+function genRoomIntro(mEvent, roomTimeline) {
+  const mx = initMatrix.matrixClient;
+  const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+  const isDM = initMatrix.roomList.directs.has(roomTimeline.roomId);
+  let avatarSrc = roomTimeline.room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
+  avatarSrc = isDM ? roomTimeline.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
+  return (
+    <RoomIntro
+      key={mEvent ? mEvent.getId() : 'room-intro'}
+      roomId={roomTimeline.roomId}
+      avatarSrc={avatarSrc}
+      name={roomTimeline.room.name}
+      heading={`Welcome to ${roomTimeline.room.name}`}
+      desc={`This is the beginning of ${roomTimeline.room.name} room.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
+      time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
+    />
+  );
+}
+
+function getMyEmojiEventId(emojiKey, eventId, roomTimeline) {
+  const mx = initMatrix.matrixClient;
+  const rEvents = roomTimeline.reactionTimeline.get(eventId);
+  let rEventId = null;
+  rEvents?.find((rE) => {
+    if (rE.getRelation() === null) return false;
+    if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
+      rEventId = rE.getId();
+      return true;
+    }
+    return false;
+  });
+  return rEventId;
+}
+
+function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
+  const myAlreadyReactEventId = getMyEmojiEventId(emojiKey, eventId, roomTimeline);
+  if (typeof myAlreadyReactEventId === 'string') {
+    if (myAlreadyReactEventId.indexOf('~') === 0) return;
+    redactEvent(roomId, myAlreadyReactEventId);
+    return;
+  }
+  sendReaction(roomId, eventId, emojiKey);
+}
+
+function pickEmoji(e, roomId, eventId, roomTimeline) {
+  const boxInfo = e.target.getBoundingClientRect();
+  openEmojiBoard({
+    x: boxInfo.x,
+    y: boxInfo.y,
+    detail: e.detail,
+  }, (emoji) => {
+    toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
+    e.target.click();
+  });
+}
+
+let wasAtBottom = true;
+function RoomViewContent({
+  roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+  const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
+  const [onStateUpdate, updateState] = useState(null);
+  const [onPagination, setOnPagination] = useState(null);
+  const [editEvent, setEditEvent] = useState(null);
+  const mx = initMatrix.matrixClient;
+
+  function autoLoadTimeline() {
+    if (timelineScroll.isScrollable() === true) return;
+    roomTimeline.paginateBack();
+  }
+  function trySendingReadReceipt() {
+    const { room, timeline } = roomTimeline;
+    if (doesRoomHaveUnread(room) && timeline.length !== 0) {
+      mx.sendReadReceipt(timeline[timeline.length - 1]);
+    }
+  }
+
+  function onReachedTop() {
+    if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
+    roomTimeline.paginateBack();
+  }
+  function toggleOnReachedBottom(isBottom) {
+    wasAtBottom = isBottom;
+    if (!isBottom) return;
+    trySendingReadReceipt();
+  }
+
+  const updatePAG = (canPagMore) => {
+    if (!canPagMore) {
+      setIsReachedTimelineEnd(true);
+    } else {
+      setOnPagination({});
+      autoLoadTimeline();
+    }
+  };
+  // force update RoomTimeline on cons.events.roomTimeline.EVENT
+  const updateRT = () => {
+    if (wasAtBottom) {
+      trySendingReadReceipt();
+    }
+    updateState({});
+  };
+
+  useEffect(() => {
+    setIsReachedTimelineEnd(false);
+    wasAtBottom = true;
+  }, [roomId]);
+  useEffect(() => trySendingReadReceipt(), [roomTimeline]);
+
+  // init room setup completed.
+  // listen for future. setup stateUpdate listener.
+  useEffect(() => {
+    roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
+    roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
+    viewEvent.on('reached-top', onReachedTop);
+    viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom);
+
+    return () => {
+      roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
+      roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
+      viewEvent.removeListener('reached-top', onReachedTop);
+      viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom);
+    };
+  }, [roomTimeline, isReachedTimelineEnd, onPagination]);
+
+  useLayoutEffect(() => {
+    timelineScroll.reachBottom();
+    autoLoadTimeline();
+  }, [roomTimeline]);
+
+  useLayoutEffect(() => {
+    if (onPagination === null) return;
+    timelineScroll.tryRestoringScroll();
+  }, [onPagination]);
+
+  useEffect(() => {
+    if (onStateUpdate === null) return;
+    if (wasAtBottom) timelineScroll.reachBottom();
+  }, [onStateUpdate]);
+
+  let prevMEvent = null;
+  function genMessage(mEvent) {
+    const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel;
+    const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
+
+    const isContentOnly = (
+      prevMEvent !== null
+      && prevMEvent.getType() !== 'm.room.member'
+      && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
+      && prevMEvent.getSender() === mEvent.getSender()
+    );
+
+    let content = mEvent.getContent().body;
+    if (typeof content === 'undefined') return null;
+    let reply = null;
+    let reactions = null;
+    let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html';
+    const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
+    const isEdited = roomTimeline.editedTimeline.has(mEvent.getId());
+    const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId());
+
+    if (isReply) {
+      const parsedContent = parseReply(content);
+      if (parsedContent !== null) {
+        const c = roomTimeline.room.currentState;
+        const displayNameToUserIds = c.getUserIdsWithDisplayName(parsedContent.displayName);
+        const ID = parsedContent.userId || displayNameToUserIds[0];
+        reply = {
+          color: colorMXID(ID || parsedContent.displayName),
+          to: parsedContent.displayName || getUsername(parsedContent.userId),
+          content: parsedContent.replyContent,
+        };
+        content = parsedContent.content;
+      }
+    }
+
+    if (isEdited) {
+      const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
+      const latestEdited = editedList[editedList.length - 1];
+      if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null;
+      const latestEditBody = latestEdited.getContent()['m.new_content'].body;
+      const parsedEditedContent = parseReply(latestEditBody);
+      isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html';
+      if (parsedEditedContent === null) {
+        content = latestEditBody;
+      } else {
+        content = parsedEditedContent.content;
+      }
+    }
+
+    if (haveReactions) {
+      reactions = [];
+      roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => {
+        if (rEvent.getRelation() === null) return;
+        function alreadyHaveThisReaction(rE) {
+          for (let i = 0; i < reactions.length; i += 1) {
+            if (reactions[i].key === rE.getRelation().key) return true;
+          }
+          return false;
+        }
+        if (alreadyHaveThisReaction(rEvent)) {
+          for (let i = 0; i < reactions.length; i += 1) {
+            if (reactions[i].key === rEvent.getRelation().key) {
+              reactions[i].users.push(rEvent.getSender());
+              if (reactions[i].isActive !== true) {
+                const myUserId = initMatrix.matrixClient.getUserId();
+                reactions[i].isActive = rEvent.getSender() === myUserId;
+                if (reactions[i].isActive) reactions[i].id = rEvent.getId();
+              }
+              break;
+            }
+          }
+        } else {
+          reactions.push({
+            id: rEvent.getId(),
+            key: rEvent.getRelation().key,
+            users: [rEvent.getSender()],
+            isActive: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
+          });
+        }
+      });
+    }
+
+    const senderMXIDColor = colorMXID(mEvent.sender.userId);
+    const userAvatar = isContentOnly ? null : (
+      <Avatar
+        imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
+        text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
+        bgColor={senderMXIDColor}
+        size="small"
+      />
+    );
+    const userHeader = isContentOnly ? null : (
+      <MessageHeader
+        userId={mEvent.sender.userId}
+        name={getUsernameOfRoomMember(mEvent.sender)}
+        color={senderMXIDColor}
+        time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+      />
+    );
+    const userReply = reply === null ? null : (
+      <MessageReply
+        name={reply.to}
+        color={reply.color}
+        content={reply.content}
+      />
+    );
+    const userContent = (
+      <MessageContent
+        isMarkdown={isMarkdown}
+        content={isMedia(mEvent) ? genMediaContent(mEvent) : content}
+        isEdited={isEdited}
+      />
+    );
+    const userReactions = reactions === null ? null : (
+      <MessageReactionGroup>
+        {
+          reactions.map((reaction) => (
+            <MessageReaction
+              key={reaction.id}
+              reaction={reaction.key}
+              users={reaction.users}
+              isActive={reaction.isActive}
+              onClick={() => {
+                toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline);
+              }}
+            />
+          ))
+        }
+        <IconButton
+          onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
+          src={EmojiAddIC}
+          size="extra-small"
+          tooltip="Add reaction"
+        />
+      </MessageReactionGroup>
+    );
+    const userOptions = (
+      <MessageOptions>
+        <IconButton
+          onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
+          src={EmojiAddIC}
+          size="extra-small"
+          tooltip="Add reaction"
+        />
+        <IconButton
+          onClick={() => {
+            viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
+          }}
+          src={ReplyArrowIC}
+          size="extra-small"
+          tooltip="Reply"
+        />
+        {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
+          <IconButton
+            onClick={() => setEditEvent(mEvent)}
+            src={PencilIC}
+            size="extra-small"
+            tooltip="Edit"
+          />
+        )}
+        <ContextMenu
+          content={() => (
+            <>
+              <MenuHeader>Options</MenuHeader>
+              <MenuItem
+                iconSrc={EmojiAddIC}
+                onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
+              >
+                Add reaction
+              </MenuItem>
+              <MenuItem
+                iconSrc={ReplyArrowIC}
+                onClick={() => {
+                  viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
+                }}
+              >
+                Reply
+              </MenuItem>
+              {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
+                <MenuItem iconSrc={PencilIC} onClick={() => setEditEvent(mEvent)}>Edit</MenuItem>
+              )}
+              <MenuItem
+                iconSrc={TickMarkIC}
+                onClick={() => openReadReceipts(roomId, mEvent.getId())}
+              >
+                Read receipts
+              </MenuItem>
+              {(canIRedact || mEvent.getSender() === mx.getUserId()) && (
+                <>
+                  <MenuBorder />
+                  <MenuItem
+                    variant="danger"
+                    iconSrc={BinIC}
+                    onClick={() => {
+                      if (window.confirm('Are you sure you want to delete this event')) {
+                        redactEvent(roomId, mEvent.getId());
+                      }
+                    }}
+                  >
+                    Delete
+                  </MenuItem>
+                </>
+              )}
+            </>
+          )}
+          render={(toggleMenu) => (
+            <IconButton
+              onClick={toggleMenu}
+              src={VerticalMenuIC}
+              size="extra-small"
+              tooltip="Options"
+            />
+          )}
+        />
+      </MessageOptions>
+    );
+
+    const isEditingEvent = editEvent?.getId() === mEvent.getId();
+    const myMessageEl = (
+      <Message
+        key={mEvent.getId()}
+        avatar={userAvatar}
+        header={userHeader}
+        reply={userReply}
+        content={editEvent !== null && isEditingEvent ? null : userContent}
+        editContent={editEvent !== null && isEditingEvent ? (
+          <MessageEdit
+            content={content}
+            onSave={(newBody) => {
+              if (newBody !== content) {
+                initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
+              }
+              setEditEvent(null);
+            }}
+            onCancel={() => setEditEvent(null)}
+          />
+        ) : null}
+        reactions={userReactions}
+        options={editEvent !== null && isEditingEvent ? null : userOptions}
+      />
+    );
+    return myMessageEl;
+  }
+
+  function renderMessage(mEvent) {
+    if (mEvent.getType() === 'm.room.create') return genRoomIntro(mEvent, roomTimeline);
+    if (
+      mEvent.getType() !== 'm.room.message'
+      && mEvent.getType() !== 'm.room.encrypted'
+      && mEvent.getType() !== 'm.room.member'
+      && mEvent.getType() !== 'm.sticker'
+    ) return false;
+    if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
+
+    // ignore if message is deleted
+    if (mEvent.isRedacted()) return false;
+
+    let divider = null;
+    if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
+      divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
+    }
+
+    if (mEvent.getType() !== 'm.room.member') {
+      const messageComp = genMessage(mEvent);
+      prevMEvent = mEvent;
+      return (
+        <React.Fragment key={`box-${mEvent.getId()}`}>
+          {divider}
+          {messageComp}
+        </React.Fragment>
+      );
+    }
+
+    prevMEvent = mEvent;
+    const timelineChange = parseTimelineChange(mEvent);
+    if (timelineChange === null) return null;
+    return (
+      <React.Fragment key={`box-${mEvent.getId()}`}>
+        {divider}
+        <TimelineChange
+          key={mEvent.getId()}
+          variant={timelineChange.variant}
+          content={timelineChange.content}
+          time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+        />
+      </React.Fragment>
+    );
+  }
+
+  return (
+    <div className="room-view__content">
+      <div className="timeline__wrapper">
+        { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() }
+        { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genRoomIntro(undefined, roomTimeline)}
+        { roomTimeline.timeline.map(renderMessage) }
+      </div>
+    </div>
+  );
+}
+RoomViewContent.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  roomTimeline: PropTypes.shape({}).isRequired,
+  timelineScroll: PropTypes.shape({}).isRequired,
+  viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewContent;
diff --git a/src/app/organisms/room/RoomViewContent.scss b/src/app/organisms/room/RoomViewContent.scss
new file mode 100644 (file)
index 0000000..cfb328c
--- /dev/null
@@ -0,0 +1,13 @@
+.room-view__content {
+  min-height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-end;
+
+  & .timeline__wrapper {
+    --typing-noti-height: 28px;
+    min-height: 0;
+    min-width: 0;
+    padding-bottom: var(--typing-noti-height);
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/room/RoomViewFloating.jsx b/src/app/organisms/room/RoomViewFloating.jsx
new file mode 100644 (file)
index 0000000..56b7a9b
--- /dev/null
@@ -0,0 +1,83 @@
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewFloating.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
+
+import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
+
+import { getUsersActionJsx } from './common';
+
+function RoomViewFloating({
+  roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+  const [reachedBottom, setReachedBottom] = useState(true);
+  const [typingMembers, setTypingMembers] = useState(new Set());
+  const mx = initMatrix.matrixClient;
+
+  function isSomeoneTyping(members) {
+    const m = members;
+    m.delete(mx.getUserId());
+    if (m.size === 0) return false;
+    return true;
+  }
+
+  function getTypingMessage(members) {
+    const userIds = members;
+    userIds.delete(mx.getUserId());
+    return getUsersActionJsx(roomId, [...userIds], 'typing...');
+  }
+
+  function updateTyping(members) {
+    setTypingMembers(members);
+  }
+
+  useEffect(() => {
+    setReachedBottom(true);
+    setTypingMembers(new Set());
+    viewEvent.on('toggle-reached-bottom', setReachedBottom);
+    return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
+  }, [roomId]);
+
+  useEffect(() => {
+    roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
+    return () => {
+      roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
+    };
+  }, [roomTimeline]);
+
+  return (
+    <>
+      <div className={`room-view__typing${isSomeoneTyping(typingMembers) ? ' room-view__typing--open' : ''}`}>
+        <div className="bouncingLoader"><div /></div>
+        <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
+      </div>
+      <div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}>
+        <IconButton
+          onClick={() => {
+            timelineScroll.enableSmoothScroll();
+            timelineScroll.reachBottom();
+            timelineScroll.disableSmoothScroll();
+          }}
+          src={ChevronBottomIC}
+          tooltip="Scroll to Bottom"
+        />
+      </div>
+    </>
+  );
+}
+RoomViewFloating.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  roomTimeline: PropTypes.shape({}).isRequired,
+  timelineScroll: PropTypes.shape({
+    reachBottom: PropTypes.func,
+  }).isRequired,
+  viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewFloating;
diff --git a/src/app/organisms/room/RoomViewFloating.scss b/src/app/organisms/room/RoomViewFloating.scss
new file mode 100644 (file)
index 0000000..501c9f4
--- /dev/null
@@ -0,0 +1,84 @@
+.room-view {
+  &__typing {
+    display: flex;
+    padding: var(--sp-ultra-tight) var(--sp-normal);
+    background: var(--bg-surface);
+    transition: transform 200ms ease-in-out;
+
+    & b {
+      color: var(--tc-surface-high);
+    }
+
+    &--open {
+      transform: translateY(-99%);
+    }
+
+    & .text {
+      flex: 1;
+      min-width: 0;
+
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      margin: 0 var(--sp-tight);
+    }
+  }
+
+  .bouncingLoader {
+    transform: translateY(2px);
+    margin: 0 calc(var(--sp-ultra-tight) / 2);
+  }
+  .bouncingLoader > div,
+  .bouncingLoader:before,
+  .bouncingLoader:after {
+    display: inline-block;
+    width: 8px;
+    height: 8px;
+    background: var(--tc-surface-high);
+    border-radius: 50%;
+    animation: bouncing-loader 0.6s infinite alternate;
+  }
+  
+  .bouncingLoader:before,
+  .bouncingLoader:after {
+    content: "";
+  }
+  
+  .bouncingLoader > div {
+    margin: 0 4px;
+  }
+  
+  .bouncingLoader > div {
+    animation-delay: 0.2s;
+  }
+  
+  .bouncingLoader:after {
+    animation-delay: 0.4s;
+  }
+  
+  @keyframes bouncing-loader {
+    to {
+      opacity: 0.1;
+      transform: translate3d(0, -4px, 0);
+    }
+  }
+
+  &__STB {
+    position: absolute;
+    right: var(--sp-normal);
+    bottom: 0;
+    border-radius: var(--bo-radius);
+    box-shadow: var(--bs-surface-border);
+    background-color: var(--bg-surface-low);
+    transition: transform 200ms ease-in-out;
+    transform: translateY(100%) scale(0);
+    [dir=rtl] & {
+      right: unset;
+      left: var(--sp-normal);
+    }
+
+    &--open {
+      transform: translateY(-28px) scale(1);
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/room/RoomViewHeader.jsx b/src/app/organisms/room/RoomViewHeader.jsx
new file mode 100644 (file)
index 0000000..d9b8aa9
--- /dev/null
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import initMatrix from '../../../client/initMatrix';
+import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation';
+import * as roomActions from '../../../client/action/room';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
+import Header, { TitleWrapper } from '../../atoms/header/Header';
+import Avatar from '../../atoms/avatar/Avatar';
+import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
+
+import UserIC from '../../../../public/res/ic/outlined/user.svg';
+import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
+import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
+import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
+
+function RoomViewHeader({ roomId }) {
+  const mx = initMatrix.matrixClient;
+  const isDM = initMatrix.roomList.directs.has(roomId);
+  let avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
+  avatarSrc = isDM ? mx.getRoom(roomId).getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop') : avatarSrc;
+  const roomName = mx.getRoom(roomId).name;
+  const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+
+  return (
+    <Header>
+      <Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomId)} size="small" />
+      <TitleWrapper>
+        <Text variant="h2">{roomName}</Text>
+        { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
+      </TitleWrapper>
+      <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
+      <ContextMenu
+        placement="bottom"
+        content={(toogleMenu) => (
+          <>
+            <MenuHeader>Options</MenuHeader>
+            {/* <MenuBorder /> */}
+            <MenuItem
+              iconSrc={AddUserIC}
+              onClick={() => {
+                openInviteUser(roomId); toogleMenu();
+              }}
+            >
+              Invite
+            </MenuItem>
+            <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId)}>Leave</MenuItem>
+          </>
+        )}
+        render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
+      />
+    </Header>
+  );
+}
+RoomViewHeader.propTypes = {
+  roomId: PropTypes.string.isRequired,
+};
+
+export default RoomViewHeader;
diff --git a/src/app/organisms/room/RoomViewInput.jsx b/src/app/organisms/room/RoomViewInput.jsx
new file mode 100644 (file)
index 0000000..a72f1e3
--- /dev/null
@@ -0,0 +1,413 @@
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewInput.scss';
+
+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 { bytesToSize } from '../../../util/common';
+import { getUsername } from '../../../util/matrixUtil';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import RawIcon from '../../atoms/system-icons/RawIcon';
+import IconButton from '../../atoms/button/IconButton';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import { MessageReply } from '../../molecules/message/Message';
+
+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 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';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+
+const CMD_REGEX = /(\/|>[#*@]|:|@)(\S*)$/;
+let isTyping = false;
+let isCmdActivated = false;
+let cmdCursorPos = null;
+function RoomViewInput({
+  roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+  const [attachment, setAttachment] = useState(null);
+  const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
+  const [replyTo, setReplyTo] = useState(null);
+
+  const textAreaRef = useRef(null);
+  const inputBaseRef = useRef(null);
+  const uploadInputRef = useRef(null);
+  const uploadProgressRef = useRef(null);
+  const rightOptionsRef = useRef(null);
+  const escBtnRef = useRef(null);
+
+  const TYPING_TIMEOUT = 5000;
+  const mx = initMatrix.matrixClient;
+  const { roomsInput } = initMatrix;
+
+  function requestFocusInput() {
+    if (textAreaRef === null) return;
+    textAreaRef.current.focus();
+  }
+
+  useEffect(() => {
+    settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
+    viewEvent.on('focus_msg_input', requestFocusInput);
+    return () => {
+      settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
+      viewEvent.removeListener('focus_msg_input', requestFocusInput);
+    };
+  }, []);
+
+  const sendIsTyping = (isT) => {
+    mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
+    isTyping = isT;
+
+    if (isT === true) {
+      setTimeout(() => {
+        if (isTyping) sendIsTyping(false);
+      }, TYPING_TIMEOUT);
+    }
+  };
+
+  function uploadingProgress(myRoomId, { loaded, total }) {
+    if (myRoomId !== roomId) return;
+    const progressPer = Math.round((loaded * 100) / total);
+    uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
+    inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
+  }
+  function clearAttachment(myRoomId) {
+    if (roomId !== myRoomId) return;
+    setAttachment(null);
+    inputBaseRef.current.style.backgroundImage = 'unset';
+    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;
+    requestAnimationFrame(() => {
+      inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-positive)';
+      escBtnRef.current.style.display = 'block';
+    });
+    rightOptionsA11Y(false);
+    viewEvent.emit('cmd_activate', prefix);
+  }
+  function deactivateCmd() {
+    if (inputBaseRef.current !== null) {
+      requestAnimationFrame(() => {
+        inputBaseRef.current.style.boxShadow = 'var(--bs-surface-border)';
+        escBtnRef.current.style.display = 'none';
+      });
+      rightOptionsA11Y(true);
+    }
+    isCmdActivated = false;
+    cmdCursorPos = null;
+  }
+  function deactivateCmdAndEmit() {
+    deactivateCmd();
+    viewEvent.emit('cmd_deactivate');
+  }
+  function errorCmd() {
+    requestAnimationFrame(() => {
+      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();
+  }
+
+  function focusInput() {
+    if (settings.isTouchScreenDevice) return;
+    textAreaRef.current.focus();
+  }
+
+  function setUpReply(userId, eventId, content) {
+    setReplyTo({ userId, eventId, content });
+    roomsInput.setReplyTo(roomId, { userId, eventId, content });
+    focusInput();
+  }
+
+  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);
+    viewEvent.on('reply_to', setUpReply);
+    if (textAreaRef?.current !== null) {
+      isTyping = false;
+      focusInput();
+      textAreaRef.current.value = roomsInput.getMessage(roomId);
+      setAttachment(roomsInput.getAttachment(roomId));
+      setReplyTo(roomsInput.getReplyTo(roomId));
+    }
+    return () => {
+      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);
+      viewEvent.removeListener('reply_to', setUpReply);
+      if (isCmdActivated) deactivateCmd();
+      if (textAreaRef?.current === null) return;
+
+      const msg = textAreaRef.current.value;
+      inputBaseRef.current.style.backgroundImage = 'unset';
+      if (msg.trim() === '') {
+        roomsInput.setMessage(roomId, '');
+        return;
+      }
+      roomsInput.setMessage(roomId, msg);
+    };
+  }, [roomId]);
+
+  async function sendMessage() {
+    const msgBody = textAreaRef.current.value;
+    if (roomsInput.isSending(roomId)) return;
+    if (msgBody.trim() === '' && attachment === null) return;
+    sendIsTyping(false);
+
+    roomsInput.setMessage(roomId, msgBody);
+    if (attachment !== null) {
+      roomsInput.setAttachment(roomId, attachment);
+    }
+    textAreaRef.current.disabled = true;
+    textAreaRef.current.style.cursor = 'not-allowed';
+    await roomsInput.sendInput(roomId);
+    textAreaRef.current.disabled = false;
+    textAreaRef.current.style.cursor = 'unset';
+    focusInput();
+
+    textAreaRef.current.value = roomsInput.getMessage(roomId);
+    timelineScroll.reachBottom();
+    viewEvent.emit('message_sent');
+    textAreaRef.current.style.height = 'unset';
+    if (replyTo !== null) setReplyTo(null);
+  }
+
+  function processTyping(msg) {
+    const isEmptyMsg = msg === '';
+
+    if (isEmptyMsg && isTyping) {
+      sendIsTyping(false);
+      return;
+    }
+    if (!isEmptyMsg && !isTyping) {
+      sendIsTyping(true);
+    }
+  }
+
+  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) deactivateCmdAndEmit();
+      return;
+    }
+    const cmdPrefix = cmdParts[1];
+    const cmdSlug = cmdParts[2];
+
+    if (cmdPrefix === ':') {
+      // skip emoji autofill command if link is suspected.
+      const checkForLink = targetInput.slice(0, cmdParts.index);
+      if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
+        deactivateCmdAndEmit();
+        return;
+      }
+    }
+
+    cmdCursorPos = cursor;
+    if (cmdSlug === '') {
+      activateCmd(cmdPrefix);
+      return;
+    }
+    if (!isCmdActivated) activateCmd(cmdPrefix);
+    requestAnimationFrame(() => {
+      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;
+    recognizeCmd(e.target.value);
+    if (!isCmdActivated) processTyping(msg);
+  }
+
+  function handleKeyDown(e) {
+    if (e.keyCode === 13 && e.shiftKey === false) {
+      e.preventDefault();
+
+      if (isCmdActivated) {
+        viewEvent.emit('cmd_exe');
+      } else sendMessage();
+    }
+    if (e.keyCode === 27 && isCmdActivated) {
+      deactivateCmdAndEmit();
+      e.preventDefault();
+    }
+  }
+
+  function addEmoji(emoji) {
+    textAreaRef.current.value += emoji.unicode;
+  }
+
+  function handleUploadClick() {
+    if (attachment === null) uploadInputRef.current.click();
+    else {
+      roomsInput.cancelAttachment(roomId);
+    }
+  }
+  function uploadFileChange(e) {
+    const file = e.target.files.item(0);
+    setAttachment(file);
+    if (file !== null) roomsInput.setAttachment(roomId, file);
+  }
+
+  function renderInputs() {
+    return (
+      <>
+        <div className={`room-input__option-container${attachment === null ? '' : ' room-attachment__option'}`}>
+          <input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
+          <IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
+        </div>
+        <div ref={inputBaseRef} className="room-input__input-container">
+          {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
+          <ScrollView autoHide>
+            <Text className="room-input__textarea-wrapper">
+              <TextareaAutosize
+                ref={textAreaRef}
+                onChange={handleMsgTyping}
+                onResize={() => timelineScroll.autoReachBottom()}
+                onKeyDown={handleKeyDown}
+                placeholder="Send a message..."
+              />
+            </Text>
+          </ScrollView>
+          {isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
+          <button ref={escBtnRef} tabIndex="-1" onClick={deactivateCmdAndEmit} className="btn-cmd-esc" type="button"><Text variant="b3">ESC</Text></button>
+        </div>
+        <div ref={rightOptionsRef} className="room-input__option-container">
+          <IconButton
+            onClick={(e) => {
+              const boxInfo = e.target.getBoundingClientRect();
+              openEmojiBoard({
+                x: boxInfo.x + (document.dir === 'rtl' ? -80 : 80),
+                y: boxInfo.y - 250,
+                detail: e.detail,
+              }, addEmoji);
+            }}
+            tooltip="Emoji"
+            src={EmojiIC}
+          />
+          <IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
+        </div>
+      </>
+    );
+  }
+
+  function attachFile() {
+    const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
+    return (
+      <div className="room-attachment">
+        <div className={`room-attachment__preview${fileType !== 'image' ? ' room-attachment__icon' : ''}`}>
+          {fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
+          {fileType === 'video' && <RawIcon src={VLCIC} />}
+          {fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
+          {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
+        </div>
+        <div className="room-attachment__info">
+          <Text variant="b1">{attachment.name}</Text>
+          <Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
+        </div>
+      </div>
+    );
+  }
+
+  function attachReply() {
+    return (
+      <div className="room-reply">
+        <IconButton
+          onClick={() => {
+            roomsInput.cancelReplyTo(roomId);
+            setReplyTo(null);
+          }}
+          src={CrossIC}
+          tooltip="Cancel reply"
+          size="extra-small"
+        />
+        <MessageReply
+          userId={replyTo.userId}
+          name={getUsername(replyTo.userId)}
+          color={colorMXID(replyTo.userId)}
+          content={replyTo.content}
+        />
+      </div>
+    );
+  }
+
+  return (
+    <>
+      { replyTo !== null && attachReply()}
+      { attachment !== null && attachFile() }
+      <form className="room-input" onSubmit={(e) => { e.preventDefault(); }}>
+        {
+          roomTimeline.room.isSpaceRoom()
+            ? <Text className="room-input__space" variant="b1">Spaces are yet to be implemented</Text>
+            : renderInputs()
+        }
+      </form>
+    </>
+  );
+}
+RoomViewInput.propTypes = {
+  roomId: PropTypes.string.isRequired,
+  roomTimeline: PropTypes.shape({}).isRequired,
+  timelineScroll: PropTypes.shape({
+    reachBottom: PropTypes.func,
+    autoReachBottom: PropTypes.func,
+    tryRestoringScroll: PropTypes.func,
+    enableSmoothScroll: PropTypes.func,
+    disableSmoothScroll: PropTypes.func,
+  }).isRequired,
+  viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewInput;
diff --git a/src/app/organisms/room/RoomViewInput.scss b/src/app/organisms/room/RoomViewInput.scss
new file mode 100644 (file)
index 0000000..112a4c4
--- /dev/null
@@ -0,0 +1,128 @@
+.room-input {
+  padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
+  display: flex;
+  min-height: 48px;
+
+  &__space {
+    min-width: 0;
+    align-self: center;
+    margin: auto;
+    padding: 0 var(--sp-tight);
+  }
+
+  &__input-container {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+
+    margin: 0 calc(var(--sp-tight)  - 2px);
+    background-color: var(--bg-surface-low);
+    box-shadow: var(--bs-surface-border);
+    border-radius: var(--bo-radius);
+
+    & > .ic-raw {
+      transform: scale(0.8);
+      margin: 0 var(--sp-extra-tight);
+    }
+
+    & .btn-cmd-esc {
+      display: none;
+      margin: 0 var(--sp-extra-tight);
+      padding: var(--sp-ultra-tight) var(--sp-extra-tight);
+      background-color: var(--bg-surface);
+      border-radius: calc(var(--bo-radius) / 2);
+      box-shadow: var(--bs-surface-border);
+      cursor: pointer;
+      & .text { color: var(--tc-surface-normal); }
+    }
+    & .scrollbar {
+      max-height: 50vh;
+      flex: 1;
+
+      &:first-child {
+        margin-left: var(--sp-tight);
+        [dir=rtl] & {
+          margin-left: 0;
+          margin-right: var(--sp-tight);
+        }
+      }
+    }
+  }
+
+  &__textarea-wrapper {
+    min-height: 40px;
+    display: flex;
+    align-items: center;
+
+    & textarea {
+      resize: none;
+      width: 100%;
+      min-width: 0;
+      min-height: 100%;
+      padding: var(--sp-ultra-tight) 0;
+
+      &::placeholder {
+        color: var(--tc-surface-low);
+      }
+      &:focus {
+        outline: none;
+      }
+    }
+  }
+}
+
+.room-attachment {
+  --side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
+  display: flex;
+  align-items: center;
+  margin-left: var(--side-spacing);
+  margin-top: var(--sp-extra-tight);
+  line-height: 0;
+  [dir=rtl] & {
+    margin-left: 0;
+    margin-right: var(--side-spacing);
+  }
+
+  &__preview > img {
+    max-height: 40px;
+    border-radius: var(--bo-radius);
+  }
+  &__icon {
+    padding: var(--sp-extra-tight);
+    background-color: var(--bg-surface-low);
+    box-shadow: var(--bs-surface-border);
+    border-radius: var(--bo-radius);
+  }
+  &__info {
+    flex: 1;
+    min-width: 0;
+    margin: 0 var(--sp-tight);
+  }
+
+  &__option button {
+    transition: transform 200ms ease-in-out;
+    transform: translateY(-48px);
+    & .ic-raw {
+      transition: transform 200ms ease-in-out;
+      transform: rotate(45deg);
+      background-color: var(--bg-caution);
+    }
+  }
+}
+
+.room-reply {
+  display: flex;
+  align-items: center;
+  background-color: var(--bg-surface-low);
+  border-bottom: 1px solid var(--bg-surface-border);
+
+  & .ic-btn-surface {
+    margin: 0 13px 0 17px;
+    border-radius: 0;
+    [dir=rtl] & {
+      margin: 0 17px 0 13px;
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/room/common.jsx b/src/app/organisms/room/common.jsx
new file mode 100644 (file)
index 0000000..2d876d7
--- /dev/null
@@ -0,0 +1,272 @@
+import React from 'react';
+
+import initMatrix from '../../../client/initMatrix';
+import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
+
+function getTimelineJSXMessages() {
+  return {
+    join(user) {
+      return (
+        <>
+          <b>{user}</b>
+          {' joined the room'}
+        </>
+      );
+    },
+    leave(user, reason) {
+      const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
+      return (
+        <>
+          <b>{user}</b>
+          {' left the room'}
+          {reasonMsg}
+        </>
+      );
+    },
+    invite(inviter, user) {
+      return (
+        <>
+          <b>{inviter}</b>
+          {' invited '}
+          <b>{user}</b>
+        </>
+      );
+    },
+    cancelInvite(inviter, user) {
+      return (
+        <>
+          <b>{inviter}</b>
+          {' canceled '}
+          <b>{user}</b>
+          {'\'s invite'}
+        </>
+      );
+    },
+    rejectInvite(user) {
+      return (
+        <>
+          <b>{user}</b>
+          {' rejected the invitation'}
+        </>
+      );
+    },
+    kick(actor, user, reason) {
+      const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
+      return (
+        <>
+          <b>{actor}</b>
+          {' kicked '}
+          <b>{user}</b>
+          {reasonMsg}
+        </>
+      );
+    },
+    ban(actor, user, reason) {
+      const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
+      return (
+        <>
+          <b>{actor}</b>
+          {' banned '}
+          <b>{user}</b>
+          {reasonMsg}
+        </>
+      );
+    },
+    unban(actor, user) {
+      return (
+        <>
+          <b>{actor}</b>
+          {' unbanned '}
+          <b>{user}</b>
+        </>
+      );
+    },
+    avatarSets(user) {
+      return (
+        <>
+          <b>{user}</b>
+          {' set the avatar'}
+        </>
+      );
+    },
+    avatarChanged(user) {
+      return (
+        <>
+          <b>{user}</b>
+          {' changed the avatar'}
+        </>
+      );
+    },
+    avatarRemoved(user) {
+      return (
+        <>
+          <b>{user}</b>
+          {' removed the avatar'}
+        </>
+      );
+    },
+    nameSets(user, newName) {
+      return (
+        <>
+          <b>{user}</b>
+          {' set the display name to '}
+          <b>{newName}</b>
+        </>
+      );
+    },
+    nameChanged(user, newName) {
+      return (
+        <>
+          <b>{user}</b>
+          {' changed the display name to '}
+          <b>{newName}</b>
+        </>
+      );
+    },
+    nameRemoved(user, lastName) {
+      return (
+        <>
+          <b>{user}</b>
+          {' removed the display name '}
+          <b>{lastName}</b>
+        </>
+      );
+    },
+  };
+}
+
+function getUsersActionJsx(roomId, userIds, actionStr) {
+  const room = initMatrix.matrixClient.getRoom(roomId);
+  const getUserDisplayName = (userId) => {
+    if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
+    return getUsername(userId);
+  };
+  const getUserJSX = (userId) => <b>{getUserDisplayName(userId)}</b>;
+  if (!Array.isArray(userIds)) return 'Idle';
+  if (userIds.length === 0) return 'Idle';
+  const MAX_VISIBLE_COUNT = 3;
+
+  const u1Jsx = getUserJSX(userIds[0]);
+  // eslint-disable-next-line react/jsx-one-expression-per-line
+  if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
+
+  const u2Jsx = getUserJSX(userIds[1]);
+  // eslint-disable-next-line react/jsx-one-expression-per-line
+  if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
+
+  const u3Jsx = getUserJSX(userIds[2]);
+  if (userIds.length === 3) {
+    // eslint-disable-next-line react/jsx-one-expression-per-line
+    return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
+  }
+
+  const othersCount = userIds.length - MAX_VISIBLE_COUNT;
+  // eslint-disable-next-line react/jsx-one-expression-per-line
+  return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}</>;
+}
+
+function parseReply(rawContent) {
+  if (rawContent.indexOf('>') !== 0) return null;
+  let content = rawContent.slice(rawContent.indexOf('<') + 1);
+  const user = content.slice(0, content.indexOf('>'));
+
+  content = content.slice(content.indexOf('>') + 2);
+  const replyContent = content.slice(0, content.indexOf('\n\n'));
+  content = content.slice(content.indexOf('\n\n') + 2);
+
+  if (user === '') return null;
+
+  const isUserId = user.match(/^@.+:.+/);
+
+  return {
+    userId: isUserId ? user : null,
+    displayName: isUserId ? null : user,
+    replyContent,
+    content,
+  };
+}
+
+function parseTimelineChange(mEvent) {
+  const tJSXMsgs = getTimelineJSXMessages();
+  const makeReturnObj = (variant, content) => ({
+    variant,
+    content,
+  });
+  const content = mEvent.getContent();
+  const prevContent = mEvent.getPrevContent();
+  const sender = mEvent.getSender();
+  const senderName = getUsername(sender);
+  const userName = getUsername(mEvent.getStateKey());
+
+  switch (content.membership) {
+    case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
+    case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
+    case 'join':
+      if (prevContent.membership === 'join') {
+        if (content.displayname !== prevContent.displayname) {
+          if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
+          if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
+          return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
+        }
+        if (content.avatar_url !== prevContent.avatar_url) {
+          if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
+          if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
+          return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
+        }
+        return null;
+      }
+      return makeReturnObj('join', tJSXMsgs.join(senderName));
+    case 'leave':
+      if (sender === mEvent.getStateKey()) {
+        switch (prevContent.membership) {
+          case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
+          default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
+        }
+      }
+      switch (prevContent.membership) {
+        case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
+        case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
+        // sender is not target and made the target leave,
+        // if not from invite/ban then this is a kick
+        default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
+      }
+    default: return null;
+  }
+}
+
+function scrollToBottom(ref) {
+  const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight;
+  // eslint-disable-next-line no-param-reassign
+  ref.current.scrollTop = maxScrollTop;
+}
+
+function isAtBottom(ref) {
+  const { scrollHeight, scrollTop, offsetHeight } = ref.current;
+  const scrollUptoBottom = scrollTop + offsetHeight;
+
+  // scroll view have to div inside div which contains messages
+  const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild;
+  const lastChildHeight = lastMessage.offsetHeight;
+
+  // auto scroll to bottom even if user has EXTRA_SPACE left to scroll
+  const EXTRA_SPACE = 48;
+
+  if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) {
+    return true;
+  }
+  return false;
+}
+
+function autoScrollToBottom(ref) {
+  if (isAtBottom(ref)) scrollToBottom(ref);
+}
+
+export {
+  getTimelineJSXMessages,
+  getUsersActionJsx,
+  parseReply,
+  parseTimelineChange,
+  scrollToBottom,
+  isAtBottom,
+  autoScrollToBottom,
+};
index 3d8b45d069a42a6e3f53ee8cb459d896381fdd8f..8f89d4354f133cc0a2fdf5804db3b7fa571a9898 100644 (file)
@@ -4,7 +4,7 @@ import './Client.scss';
 import Text from '../../atoms/text/Text';
 import Spinner from '../../atoms/spinner/Spinner';
 import Navigation from '../../organisms/navigation/Navigation';
-import Channel from '../../organisms/channel/Channel';
+import Room from '../../organisms/room/Room';
 import Windows from '../../organisms/pw/Windows';
 import Dialogs from '../../organisms/pw/Dialogs';
 import EmojiBoardOpener from '../../organisms/emoji-board/EmojiBoardOpener';
@@ -38,8 +38,8 @@ function Client() {
       <div className="navigation__wrapper">
         <Navigation />
       </div>
-      <div className="channel__wrapper">
-        <Channel />
+      <div className="room__wrapper">
+        <Room />
       </div>
       <Windows />
       <Dialogs />
index f1d901ef3337e47e6484fc0e44e24d573acbffa3..05280985b843c8090ec06cbde766f5a861464703 100644 (file)
@@ -6,7 +6,7 @@
 .navigation__wrapper {
   width: var(--navigation-width);
 }
-.channel__wrapper {
+.room__wrapper {
   flex: 1;
   min-width: 0;
   background-color: var(--bg-surface);
index 78c001f0eeaa10b95402cce96f93fda9dd6b54d5..cf40b4ae2e979e92e043bc5b349a228f19fd9c0a 100644 (file)
@@ -27,16 +27,16 @@ function openInviteList() {
   });
 }
 
-function openPublicChannels(searchTerm) {
+function openPublicRooms(searchTerm) {
   appDispatcher.dispatch({
-    type: cons.actions.navigation.OPEN_PUBLIC_CHANNELS,
+    type: cons.actions.navigation.OPEN_PUBLIC_ROOMS,
     searchTerm,
   });
 }
 
-function openCreateChannel() {
+function openCreateRoom() {
   appDispatcher.dispatch({
-    type: cons.actions.navigation.OPEN_CREATE_CHANNEL,
+    type: cons.actions.navigation.OPEN_CREATE_ROOM,
   });
 }
 
@@ -75,8 +75,8 @@ export {
   selectRoom,
   togglePeopleDrawer,
   openInviteList,
-  openPublicChannels,
-  openCreateChannel,
+  openPublicRooms,
+  openCreateRoom,
   openInviteUser,
   openSettings,
   openEmojiBoard,
index b5de3d669f59474bd44da2bdf957fbbe1503c570..f5e92b0f527b85f022ff4a55b985b63170585017 100644 (file)
@@ -12,8 +12,8 @@ const cons = {
       SELECT_ROOM: 'SELECT_ROOM',
       TOGGLE_PEOPLE_DRAWER: 'TOGGLE_PEOPLE_DRAWER',
       OPEN_INVITE_LIST: 'OPEN_INVITE_LIST',
-      OPEN_PUBLIC_CHANNELS: 'OPEN_PUBLIC_CHANNELS',
-      OPEN_CREATE_CHANNEL: 'OPEN_CREATE_CHANNEL',
+      OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS',
+      OPEN_CREATE_ROOM: 'OPEN_CREATE_ROOM',
       OPEN_INVITE_USER: 'OPEN_INVITE_USER',
       OPEN_SETTINGS: 'OPEN_SETTINGS',
       OPEN_EMOJIBOARD: 'OPEN_EMOJIBOARD',
@@ -37,8 +37,8 @@ const cons = {
       ROOM_SELECTED: 'ROOM_SELECTED',
       PEOPLE_DRAWER_TOGGLED: 'PEOPLE_DRAWER_TOGGLED',
       INVITE_LIST_OPENED: 'INVITE_LIST_OPENED',
-      PUBLIC_CHANNELS_OPENED: 'PUBLIC_CHANNELS_OPENED',
-      CREATE_CHANNEL_OPENED: 'CREATE_CHANNEL_OPENED',
+      PUBLIC_ROOMS_OPENED: 'PUBLIC_ROOMS_OPENED',
+      CREATE_ROOM_OPENED: 'CREATE_ROOM_OPENED',
       INVITE_USER_OPENED: 'INVITE_USER_OPENED',
       SETTINGS_OPENED: 'SETTINGS_OPENED',
       EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED',
index 1aa6c0c24ea4e41fa3d435db811e8e08c895c424..5c108af908bd8020027794b48abdc78748116890 100644 (file)
@@ -37,11 +37,11 @@ class Navigation extends EventEmitter {
       [cons.actions.navigation.OPEN_INVITE_LIST]: () => {
         this.emit(cons.events.navigation.INVITE_LIST_OPENED);
       },
-      [cons.actions.navigation.OPEN_PUBLIC_CHANNELS]: () => {
-        this.emit(cons.events.navigation.PUBLIC_CHANNELS_OPENED, action.searchTerm);
+      [cons.actions.navigation.OPEN_PUBLIC_ROOMS]: () => {
+        this.emit(cons.events.navigation.PUBLIC_ROOMS_OPENED, action.searchTerm);
       },
-      [cons.actions.navigation.OPEN_CREATE_CHANNEL]: () => {
-        this.emit(cons.events.navigation.CREATE_CHANNEL_OPENED);
+      [cons.actions.navigation.OPEN_CREATE_ROOM]: () => {
+        this.emit(cons.events.navigation.CREATE_ROOM_OPENED);
       },
       [cons.actions.navigation.OPEN_INVITE_USER]: () => {
         this.emit(cons.events.navigation.INVITE_USER_OPENED, action.roomId, action.searchTerm);