From: unknown Date: Tue, 31 Aug 2021 13:13:31 +0000 (+0530) Subject: Renamed channels to rooms (#30) X-Git-Tag: v1.3.0^2~33 X-Git-Url: https://git.wafflesoft.org/?a=commitdiff_plain;h=705910d9e0650c972361f6d5bd5aa944452ccba5;p=cinny.git Renamed channels to rooms (#30) --- diff --git a/src/app/molecules/channel-intro/ChannelIntro.jsx b/src/app/molecules/channel-intro/ChannelIntro.jsx deleted file mode 100644 index 362dc14..0000000 --- a/src/app/molecules/channel-intro/ChannelIntro.jsx +++ /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 {content}; -} - -function ChannelIntro({ - roomId, avatarSrc, name, heading, desc, time, -}) { - return ( -
- -
- {heading} - {linkifyContent(desc)} - { time !== null && {time}} -
-
- ); -} - -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 index 35186af..0000000 --- a/src/app/molecules/channel-intro/ChannelIntro.scss +++ /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 index 076b5fe..0000000 --- a/src/app/molecules/channel-selector/ChannelSelector.jsx +++ /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 ( -
- -
{options}
-
- ); -} -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 ( - - - {name} - { isUnread && ( - - )} - - )} - 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 index 31385f3..0000000 --- a/src/app/molecules/channel-selector/ChannelSelector.scss +++ /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 index dfb384d..0000000 --- a/src/app/molecules/channel-tile/ChannelTile.jsx +++ /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 {content}; -} - -function ChannelTile({ - avatarSrc, name, id, - inviterName, memberCount, desc, options, -}) { - return ( -
-
- -
-
- {name} - - { - inviterName !== null - ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` • ${memberCount} members`}` - : id + (memberCount === null ? '' : ` • ${memberCount} members`) - } - - { - desc !== null && (typeof desc === 'string') - ? {linkifyContent(desc)} - : desc - } -
- { options !== null && ( -
- {options} -
- )} -
- ); -} - -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 index ce20195..0000000 --- a/src/app/molecules/channel-tile/ChannelTile.scss +++ /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 index 0000000..df5618d --- /dev/null +++ b/src/app/molecules/room-intro/RoomIntro.jsx @@ -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 {content}; +} + +function RoomIntro({ + roomId, avatarSrc, name, heading, desc, time, +}) { + return ( +
+ +
+ {heading} + {linkifyContent(desc)} + { time !== null && {time}} +
+
+ ); +} + +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 index 0000000..8e923f3 --- /dev/null +++ b/src/app/molecules/room-intro/RoomIntro.scss @@ -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 index 0000000..01e2ffc --- /dev/null +++ b/src/app/molecules/room-selector/RoomSelector.jsx @@ -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 ( +
+ +
{options}
+
+ ); +} +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 ( + + + {name} + { isUnread && ( + + )} + + )} + 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 index 0000000..61e2cbc --- /dev/null +++ b/src/app/molecules/room-selector/RoomSelector.scss @@ -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 index 0000000..a9a680d --- /dev/null +++ b/src/app/molecules/room-tile/RoomTile.jsx @@ -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 {content}; +} + +function RoomTile({ + avatarSrc, name, id, + inviterName, memberCount, desc, options, +}) { + return ( +
+
+ +
+
+ {name} + + { + inviterName !== null + ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` • ${memberCount} members`}` + : id + (memberCount === null ? '' : ` • ${memberCount} members`) + } + + { + desc !== null && (typeof desc === 'string') + ? {linkifyContent(desc)} + : desc + } +
+ { options !== null && ( +
+ {options} +
+ )} +
+ ); +} + +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 index 0000000..bbed710 --- /dev/null +++ b/src/app/molecules/room-tile/RoomTile.scss @@ -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 index d980152..0000000 --- a/src/app/organisms/channel/Channel.jsx +++ /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 ; - - return ( -
- - { isDrawerVisible && } -
- ); -} - -export default Channel; diff --git a/src/app/organisms/channel/Channel.scss b/src/app/organisms/channel/Channel.scss deleted file mode 100644 index 1d6b6ee..0000000 --- a/src/app/organisms/channel/Channel.scss +++ /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 index 07b9bf1..0000000 --- a/src/app/organisms/channel/ChannelView.jsx +++ /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 ( -
- -
-
- - {roomTimeline !== null && ( - - )} - - {roomTimeline !== null && ( - - )} -
- {roomTimeline !== null && ( -
- - -
- )} -
-
- ); -} -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 index a50a9ae..0000000 --- a/src/app/organisms/channel/ChannelView.scss +++ /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 index 40d3ff5..0000000 --- a/src/app/organisms/channel/ChannelViewCmdBar.jsx +++ /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 ( - - General command - /command_name - Go-to commands - {'>*space_name'} - {'>#channel_name'} - {'>@people_name'} - Autofill command - :emoji_name: - @name - - )} - render={(toggleMenu) => ( - - )} - /> - ); -} - -function ViewCmd() { - function renderAllCmds() { - return commands.map((command) => ( - {command.description})} - /> - )); - } - return ( - - General commands - {renderAllCmds()} - - )} - render={(toggleMenu) => ( - - - - )} - /> - ); -} - -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 && ( - 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 ( - <> - {prime} - {secondary} - - ); - } - 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 ( - - ); -} -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) => ( - { - fireCmd({ - prefix: cmdPrefix, - option, - result: cmd, - }); - }} - > - {`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`} - - )); - } - - function getRoomsSuggestion(cmdPrefix, rooms) { - return rooms.map((room) => ( - { - fireCmd({ - prefix: cmdPrefix, - result: room, - }); - }} - > - {room.name} - - )); - } - - function getEmojiSuggestion(emPrefix, emos) { - return emos.map((emoji) => ( - fireCmd({ - prefix: emPrefix, - result: emoji, - })} - > - { - parse(twemoji.parse( - emoji.unicode, - { - attributes: () => ({ - unicode: emoji.unicode, - shortcodes: emoji.shortcodes?.toString(), - }), - }, - )) - } - {`:${emoji.shortcode}:`} - - )); - } - - function getNameSuggestion(namePrefix, members) { - return members.map((member) => ( - { - fireCmd({ - prefix: namePrefix, - result: member, - }); - }} - > - {member.name} - - )); - } - - 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 ( -
-
-
-
-
- {cmd.error} -
-
- ); - } - - return ( -
-
- {cmd === null && } - {cmd !== null && typeof cmd.suggestions === 'undefined' &&
} - {cmd !== null && typeof cmd.suggestions !== 'undefined' && TAB} -
-
- {cmd === null && ( - - )} - {cmd !== null && typeof cmd.suggestions === 'undefined' && {getCmdActivationMessage(cmd.prefix)}} - {cmd !== null && typeof cmd.suggestions !== 'undefined' && ( - -
{getCmdSuggestions(cmd, fireCmd)}
-
- )} -
-
- {cmd !== null && cmd.prefix === '/' && } -
-
- ); -} -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 index dc8a981..0000000 --- a/src/app/organisms/channel/ChannelViewCmdBar.scss +++ /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 index 063718b..0000000 --- a/src/app/organisms/channel/ChannelViewContent.jsx +++ /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 ( - <> - - - - - ); -} - -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 Malformed event; - - 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 Malformed event; - - let msgType = mE.getContent()?.msgtype; - if (mE.getType() === 'm.sticker') msgType = 'm.image'; - - switch (msgType) { - case 'm.file': - return ( - - ); - case 'm.image': - return ( - - ); - case 'm.audio': - return ( - - ); - case 'm.video': - if (typeof thumbnailMXC === 'undefined') { - thumbnailMXC = mContent.info?.thumbnail_file?.url || null; - } - return ( - - ); - default: - return Malformed event; - } -} - -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 ( - - ); -} - -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 : ( - - ); - const userHeader = isContentOnly ? null : ( - - ); - const userReply = reply === null ? null : ( - - ); - const userContent = ( - - ); - const userReactions = reactions === null ? null : ( - - { - reactions.map((reaction) => ( - { - toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline); - }} - /> - )) - } - pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} - src={EmojiAddIC} - size="extra-small" - tooltip="Add reaction" - /> - - ); - const userOptions = ( - - pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} - src={EmojiAddIC} - size="extra-small" - tooltip="Add reaction" - /> - { - 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)) && ( - setEditEvent(mEvent)} - src={PencilIC} - size="extra-small" - tooltip="Edit" - /> - )} - ( - <> - Options - pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} - > - Add reaction - - { - viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); - }} - > - Reply - - {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && ( - setEditEvent(mEvent)}>Edit - )} - openReadReceipts(roomId, mEvent.getId())} - > - Read receipts - - {(canIRedact || mEvent.getSender() === mx.getUserId()) && ( - <> - - { - if (window.confirm('Are you sure you want to delete this event')) { - redactEvent(roomId, mEvent.getId()); - } - }} - > - Delete - - - )} - - )} - render={(toggleMenu) => ( - - )} - /> - - ); - - const isEditingEvent = editEvent?.getId() === mEvent.getId(); - const myMessageEl = ( - { - 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 = ; - } - - if (mEvent.getType() !== 'm.room.member') { - const messageComp = genMessage(mEvent); - prevMEvent = mEvent; - return ( - - {divider} - {messageComp} - - ); - } - - prevMEvent = mEvent; - const timelineChange = parseTimelineChange(mEvent); - if (timelineChange === null) return null; - return ( - - {divider} - - - ); - } - - return ( -
-
- { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() } - { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genChannelIntro(undefined, roomTimeline)} - { roomTimeline.timeline.map(renderMessage) } -
-
- ); -} -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 index f270233..0000000 --- a/src/app/organisms/channel/ChannelViewContent.scss +++ /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 index e3e65da..0000000 --- a/src/app/organisms/channel/ChannelViewFloating.jsx +++ /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 ( - <> -
-
- {getTypingMessage(typingMembers)} -
-
- { - timelineScroll.enableSmoothScroll(); - timelineScroll.reachBottom(); - timelineScroll.disableSmoothScroll(); - }} - src={ChevronBottomIC} - tooltip="Scroll to Bottom" - /> -
- - ); -} -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 index 3c1593c..0000000 --- a/src/app/organisms/channel/ChannelViewFloating.scss +++ /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 index f89b634..0000000 --- a/src/app/organisms/channel/ChannelViewHeader.jsx +++ /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 ( -
- - - {roomName} - { typeof roomTopic !== 'undefined' &&

{roomTopic}

} -
- - ( - <> - Options - {/* */} - { - openInviteUser(roomId); toogleMenu(); - }} - > - Invite - - roomActions.leave(roomId)}>Leave - - )} - render={(toggleMenu) => } - /> -
- ); -} -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 index f335bb4..0000000 --- a/src/app/organisms/channel/ChannelViewInput.jsx +++ /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 ( - <> -
- - -
-
- {roomTimeline.isEncryptedRoom() && } - - - timelineScroll.autoReachBottom()} - onKeyDown={handleKeyDown} - placeholder="Send a message..." - /> - - - {isMarkdown && } - -
-
- { - 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} - /> - -
- - ); - } - - function attachFile() { - const fileType = attachment.type.slice(0, attachment.type.indexOf('/')); - return ( -
-
- {fileType === 'image' && {attachment.name}} - {fileType === 'video' && } - {fileType === 'audio' && } - {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && } -
-
- {attachment.name} - {`size: ${bytesToSize(attachment.size)}`} -
-
- ); - } - - function attachReply() { - return ( -
- { - roomsInput.cancelReplyTo(roomId); - setReplyTo(null); - }} - src={CrossIC} - tooltip="Cancel reply" - size="extra-small" - /> - -
- ); - } - - return ( - <> - { replyTo !== null && attachReply()} - { attachment !== null && attachFile() } -
{ e.preventDefault(); }}> - { - roomTimeline.room.isSpaceRoom() - ? Spaces are yet to be implemented - : renderInputs() - } -
- - ); -} -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 index 2bc0121..0000000 --- a/src/app/organisms/channel/ChannelViewInput.scss +++ /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 index 2a7b18d..0000000 --- a/src/app/organisms/channel/PeopleDrawer.jsx +++ /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 ( -
-
- - - People - {`${room.getJoinedMemberCount()} members`} - - - openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} /> -
-
-
- -
- { - memberList.map((member) => ( - 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)} - /> - )) - } -
- { - memberList.length !== totalMemberList.length && ( - - ) - } -
-
-
-
-
-
e.preventDefault()} className="people-search"> - -
-
-
-
- ); -} - -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 index 56ac29e..0000000 --- a/src/app/organisms/channel/PeopleDrawer.scss +++ /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 index 46fbc5d..0000000 --- a/src/app/organisms/channel/common.jsx +++ /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 ( - <> - {user} - {' joined the channel'} - - ); - }, - leave(user, reason) { - const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; - return ( - <> - {user} - {' left the channel'} - {reasonMsg} - - ); - }, - invite(inviter, user) { - return ( - <> - {inviter} - {' invited '} - {user} - - ); - }, - cancelInvite(inviter, user) { - return ( - <> - {inviter} - {' canceled '} - {user} - {'\'s invite'} - - ); - }, - rejectInvite(user) { - return ( - <> - {user} - {' rejected the invitation'} - - ); - }, - kick(actor, user, reason) { - const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; - return ( - <> - {actor} - {' kicked '} - {user} - {reasonMsg} - - ); - }, - ban(actor, user, reason) { - const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; - return ( - <> - {actor} - {' banned '} - {user} - {reasonMsg} - - ); - }, - unban(actor, user) { - return ( - <> - {actor} - {' unbanned '} - {user} - - ); - }, - avatarSets(user) { - return ( - <> - {user} - {' set the avatar'} - - ); - }, - avatarChanged(user) { - return ( - <> - {user} - {' changed the avatar'} - - ); - }, - avatarRemoved(user) { - return ( - <> - {user} - {' removed the avatar'} - - ); - }, - nameSets(user, newName) { - return ( - <> - {user} - {' set the display name to '} - {newName} - - ); - }, - nameChanged(user, newName) { - return ( - <> - {user} - {' changed the display name to '} - {newName} - - ); - }, - nameRemoved(user, lastName) { - return ( - <> - {user} - {' removed the display name '} - {lastName} - - ); - }, - }; -} - -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) => {getUserDisplayName(userId)}; - 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 index c44b536..0000000 --- a/src/app/organisms/create-channel/CreateChannel.jsx +++ /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 ( - } - onRequestClose={onRequestClose} - > -
-
{ e.preventDefault(); createRoom(); }}> - } - content={Public channel can be joined by anyone.} - /> - {isPublic && ( -
- Channel address -
- # - - {hsString} -
- {isValidAddress === false && {`#${addressValue}${hsString} is already in use`}} -
- )} - {!isPublic && ( - } - content={You can’t disable this later. Bridges & most bots won’t work yet.} - /> - )} - -
- - -
- {isCreatingRoom && ( -
- - Creating channel... -
- )} - {typeof creatingError === 'string' && {creatingError}} - -
-
- ); -} - -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 index 6d59f65..0000000 --- a/src/app/organisms/create-channel/CreateChannel.scss +++ /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 index 0000000..d94c4b1 --- /dev/null +++ b/src/app/organisms/create-room/CreateRoom.jsx @@ -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 ( + } + onRequestClose={onRequestClose} + > +
+
{ e.preventDefault(); createRoom(); }}> + } + content={Public room can be joined by anyone.} + /> + {isPublic && ( +
+ Room address +
+ # + + {hsString} +
+ {isValidAddress === false && {`#${addressValue}${hsString} is already in use`}} +
+ )} + {!isPublic && ( + } + content={You can’t disable this later. Bridges & most bots won’t work yet.} + /> + )} + +
+ + +
+ {isCreatingRoom && ( +
+ + Creating room... +
+ )} + {typeof creatingError === 'string' && {creatingError}} + +
+
+ ); +} + +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 index 0000000..c587fa2 --- /dev/null +++ b/src/app/organisms/create-room/CreateRoom.scss @@ -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 diff --git a/src/app/organisms/invite-list/InviteList.jsx b/src/app/organisms/invite-list/InviteList.jsx index 297478e..2fee050 100644 --- a/src/app/organisms/invite-list/InviteList.jsx +++ b/src/app/organisms/invite-list/InviteList.jsx @@ -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 ( - Spaces
)} - { Array.from(initMatrix.roomList.inviteSpaces).map(renderChannelTile) } + { Array.from(initMatrix.roomList.inviteSpaces).map(renderRoomTile) } { initMatrix.roomList.inviteRooms.size !== 0 && (
- Channels + Rooms
)} - { Array.from(initMatrix.roomList.inviteRooms).map(renderChannelTile) } + { Array.from(initMatrix.roomList.inviteRooms).map(renderRoomTile) }
); diff --git a/src/app/organisms/invite-list/InviteList.scss b/src/app/organisms/invite-list/InviteList.scss index bdb78c4..70e82c7 100644 --- a/src/app/organisms/invite-list/InviteList.scss +++ b/src/app/organisms/invite-list/InviteList.scss @@ -14,7 +14,7 @@ } } - & .channel-tile { + & .room-tile { margin-top: var(--sp-normal); &__options { align-self: flex-end; diff --git a/src/app/organisms/invite-user/InviteUser.jsx b/src/app/organisms/invite-user/InviteUser.jsx index cac9060..a6ff242 100644 --- a/src/app/organisms/invite-user/InviteUser.jsx +++ b/src/app/organisms/invite-user/InviteUser.jsx @@ -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 ( -
-
+
-
+
{ activeTab === 'home' ? diff --git a/src/app/organisms/navigation/Drawer.scss b/src/app/organisms/navigation/Drawer.scss index 4b12bce..e5d3f71 100644 --- a/src/app/organisms/navigation/Drawer.scss +++ b/src/app/organisms/navigation/Drawer.scss @@ -28,14 +28,14 @@ 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); } diff --git a/src/app/organisms/navigation/DrawerHeader.jsx b/src/app/organisms/navigation/DrawerHeader.jsx index c86b09b..8915536 100644 --- a/src/app/organisms/navigation/DrawerHeader.jsx +++ b/src/app/organisms/navigation/DrawerHeader.jsx @@ -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 }) { ( <> - Add channel + Add room { hideMenu(); openCreateChannel(); }} + onClick={() => { hideMenu(); openCreateRoom(); }} > - Create new channel + Create new room { hideMenu(); openPublicChannels(); }} + onClick={() => { hideMenu(); openPublicRooms(); }} > - Add Public channel + Add public room )} - render={(toggleMenu) => ()} + render={(toggleMenu) => ()} /> )} {/* ''} tooltip="Menu" src={VerticalMenuIC} size="normal" /> */} diff --git a/src/app/organisms/navigation/Home.jsx b/src/app/organisms/navigation/Home.jsx index 80cd3c0..a39ad5d 100644 --- a/src/app/organisms/navigation/Home.jsx +++ b/src/app/organisms/navigation/Home.jsx @@ -70,7 +70,7 @@ function Home() { /> ))} - { roomIds.length !== 0 && Channels } + { roomIds.length !== 0 && Rooms } { roomIds.map((id) => (
changeTab('home')} tooltip="Home" iconSrc={HomeIC} /> - changeTab('dms')} tooltip="People" iconSrc={UserIC} /> - openPublicChannels()} tooltip="Public channels" iconSrc={HashSearchIC} /> + changeTab('dm')} tooltip="People" iconSrc={UserIC} /> + openPublicRooms()} tooltip="Public rooms" iconSrc={HashSearchIC} />
diff --git a/src/app/organisms/public-channels/PublicChannels.jsx b/src/app/organisms/public-channels/PublicChannels.jsx deleted file mode 100644 index b7388e5..0000000 --- a/src/app/organisms/public-channels/PublicChannels.jsx +++ /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 ( -
- {status.roomId === null && !status.isJoining && status.error === null && ( - - )} - {status.isJoining && ( - <> - - {`Joining ${alias}...`} - - )} - {status.roomId !== null && ( - - )} - {status.error !== null && {status.error}} -
- ); -} - -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 ( - - {isJoined && } - {!isJoined && (joiningChannels.has(channel.room_id) ? : )} - - )} - /> - ); - }); - } - - return ( - } - onRequestClose={onRequestClose} - > -
-
{ e.preventDefault(); searchChannels(); }}> -
- - -
- -
-
- { - typeof searchQuery.name !== 'undefined' && isSearching && ( - searchQuery.name === '' - ? ( -
- - {`Loading public channels from ${searchQuery.homeserver}...`} -
- ) - : ( -
- - {`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`} -
- ) - ) - } - { - typeof searchQuery.name !== 'undefined' && !isSearching && ( - searchQuery.name === '' - ? {`Public channels on ${searchQuery.homeserver}.`} - : {`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`} - ) - } - { searchQuery.error && ( - <> - {searchQuery.error} - {typeof searchQuery.alias === 'string' && ( - - )} - - )} -
- { publicChannels.length !== 0 && ( -
- { renderChannelList(publicChannels) } -
- )} - { publicChannels.length !== 0 && publicChannels.length % SEARCH_LIMIT === 0 && ( -
- { isViewMore !== true && ( - - )} - { isViewMore && } -
- )} -
-
- ); -} - -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 index 3eef310..0000000 --- a/src/app/organisms/public-channels/PublicChannels.scss +++ /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 index 0000000..b8f9244 --- /dev/null +++ b/src/app/organisms/public-rooms/PublicRooms.jsx @@ -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 ( +
+ {status.roomId === null && !status.isJoining && status.error === null && ( + + )} + {status.isJoining && ( + <> + + {`Joining ${alias}...`} + + )} + {status.roomId !== null && ( + + )} + {status.error !== null && {status.error}} +
+ ); +} + +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 ( + + {isJoined && } + {!isJoined && (joiningRooms.has(room.room_id) ? : )} + + )} + /> + ); + }); + } + + return ( + } + onRequestClose={onRequestClose} + > +
+
{ e.preventDefault(); searchRooms(); }}> +
+ + +
+ +
+
+ { + typeof searchQuery.name !== 'undefined' && isSearching && ( + searchQuery.name === '' + ? ( +
+ + {`Loading public rooms from ${searchQuery.homeserver}...`} +
+ ) + : ( +
+ + {`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`} +
+ ) + ) + } + { + typeof searchQuery.name !== 'undefined' && !isSearching && ( + searchQuery.name === '' + ? {`Public rooms on ${searchQuery.homeserver}.`} + : {`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`} + ) + } + { searchQuery.error && ( + <> + {searchQuery.error} + {typeof searchQuery.alias === 'string' && ( + + )} + + )} +
+ { publicRooms.length !== 0 && ( +
+ { renderRoomList(publicRooms) } +
+ )} + { publicRooms.length !== 0 && publicRooms.length % SEARCH_LIMIT === 0 && ( +
+ { isViewMore !== true && ( + + )} + { isViewMore && } +
+ )} +
+
+ ); +} + +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 index 0000000..66b77c0 --- /dev/null +++ b/src/app/organisms/public-rooms/PublicRooms.scss @@ -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 diff --git a/src/app/organisms/pw/Windows.jsx b/src/app/organisms/pw/Windows.jsx index 8a0afd3..32a0ee1 100644 --- a/src/app/organisms/pw/Windows.jsx +++ b/src/app/organisms/pw/Windows.jsx @@ -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)} /> - changePublicChannels({ isOpen: false, searchTerm: undefined })} + changePublicRooms({ isOpen: false, searchTerm: undefined })} /> - changeCreateChannel(false)} + changeCreateRoom(false)} /> 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 ( +
+
+ + + People + {`${room.getJoinedMemberCount()} members`} + + + openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} /> +
+
+
+ +
+ { + memberList.map((member) => ( + 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)} + /> + )) + } +
+ { + memberList.length !== totalMemberList.length && ( + + ) + } +
+
+
+
+
+
e.preventDefault()} className="people-search"> + +
+
+
+
+ ); +} + +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 index 0000000..56ac29e --- /dev/null +++ b/src/app/organisms/room/PeopleDrawer.scss @@ -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 index 0000000..6112d2b --- /dev/null +++ b/src/app/organisms/room/Room.jsx @@ -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 ; + + return ( +
+ + { isDrawerVisible && } +
+ ); +} + +export default Room; diff --git a/src/app/organisms/room/Room.scss b/src/app/organisms/room/Room.scss new file mode 100644 index 0000000..cea4bad --- /dev/null +++ b/src/app/organisms/room/Room.scss @@ -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 index 0000000..edb427d --- /dev/null +++ b/src/app/organisms/room/RoomView.jsx @@ -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 ( +
+ +
+
+ + {roomTimeline !== null && ( + + )} + + {roomTimeline !== null && ( + + )} +
+ {roomTimeline !== null && ( +
+ + +
+ )} +
+
+ ); +} +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 index 0000000..dd7e961 --- /dev/null +++ b/src/app/organisms/room/RoomView.scss @@ -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 index 0000000..7f8f809 --- /dev/null +++ b/src/app/organisms/room/RoomViewCmdBar.jsx @@ -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 ( + + General command + /command_name + Go-to commands + {'>*space_name'} + {'>#room_name'} + {'>@people_name'} + Autofill commands + :emoji_name + @name + + )} + render={(toggleMenu) => ( + + )} + /> + ); +} + +function ViewCmd() { + function renderAllCmds() { + return commands.map((command) => ( + {command.description})} + /> + )); + } + return ( + + General commands + {renderAllCmds()} + + )} + render={(toggleMenu) => ( + + + + )} + /> + ); +} + +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 && ( + 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 ( + <> + {prime} + {secondary} + + ); + } + 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 ( + + ); +} +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) => ( + { + fireCmd({ + prefix: cmdPrefix, + option, + result: cmd, + }); + }} + > + {`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`} + + )); + } + + function getRoomsSuggestion(cmdPrefix, rooms) { + return rooms.map((room) => ( + { + fireCmd({ + prefix: cmdPrefix, + result: room, + }); + }} + > + {room.name} + + )); + } + + function getEmojiSuggestion(emPrefix, emos) { + return emos.map((emoji) => ( + fireCmd({ + prefix: emPrefix, + result: emoji, + })} + > + { + parse(twemoji.parse( + emoji.unicode, + { + attributes: () => ({ + unicode: emoji.unicode, + shortcodes: emoji.shortcodes?.toString(), + }), + }, + )) + } + {`:${emoji.shortcode}:`} + + )); + } + + function getNameSuggestion(namePrefix, members) { + return members.map((member) => ( + { + fireCmd({ + prefix: namePrefix, + result: member, + }); + }} + > + {member.name} + + )); + } + + 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 ( +
+
+
+
+
+ {cmd.error} +
+
+ ); + } + + return ( +
+
+ {cmd === null && } + {cmd !== null && typeof cmd.suggestions === 'undefined' &&
} + {cmd !== null && typeof cmd.suggestions !== 'undefined' && TAB} +
+
+ {cmd === null && ( + + )} + {cmd !== null && typeof cmd.suggestions === 'undefined' && {getCmdActivationMessage(cmd.prefix)}} + {cmd !== null && typeof cmd.suggestions !== 'undefined' && ( + +
{getCmdSuggestions(cmd, fireCmd)}
+
+ )} +
+
+ {cmd !== null && cmd.prefix === '/' && } +
+
+ ); +} +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 index 0000000..dc8a981 --- /dev/null +++ b/src/app/organisms/room/RoomViewCmdBar.scss @@ -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 index 0000000..18b8d34 --- /dev/null +++ b/src/app/organisms/room/RoomViewContent.jsx @@ -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 ( + <> + + + + + ); +} + +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 Malformed event; + + 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 Malformed event; + + let msgType = mE.getContent()?.msgtype; + if (mE.getType() === 'm.sticker') msgType = 'm.image'; + + switch (msgType) { + case 'm.file': + return ( + + ); + case 'm.image': + return ( + + ); + case 'm.audio': + return ( + + ); + case 'm.video': + if (typeof thumbnailMXC === 'undefined') { + thumbnailMXC = mContent.info?.thumbnail_file?.url || null; + } + return ( + + ); + default: + return Malformed event; + } +} + +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 ( + + ); +} + +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 : ( + + ); + const userHeader = isContentOnly ? null : ( + + ); + const userReply = reply === null ? null : ( + + ); + const userContent = ( + + ); + const userReactions = reactions === null ? null : ( + + { + reactions.map((reaction) => ( + { + toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline); + }} + /> + )) + } + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + src={EmojiAddIC} + size="extra-small" + tooltip="Add reaction" + /> + + ); + const userOptions = ( + + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + src={EmojiAddIC} + size="extra-small" + tooltip="Add reaction" + /> + { + 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)) && ( + setEditEvent(mEvent)} + src={PencilIC} + size="extra-small" + tooltip="Edit" + /> + )} + ( + <> + Options + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + > + Add reaction + + { + viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); + }} + > + Reply + + {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && ( + setEditEvent(mEvent)}>Edit + )} + openReadReceipts(roomId, mEvent.getId())} + > + Read receipts + + {(canIRedact || mEvent.getSender() === mx.getUserId()) && ( + <> + + { + if (window.confirm('Are you sure you want to delete this event')) { + redactEvent(roomId, mEvent.getId()); + } + }} + > + Delete + + + )} + + )} + render={(toggleMenu) => ( + + )} + /> + + ); + + const isEditingEvent = editEvent?.getId() === mEvent.getId(); + const myMessageEl = ( + { + 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 = ; + } + + if (mEvent.getType() !== 'm.room.member') { + const messageComp = genMessage(mEvent); + prevMEvent = mEvent; + return ( + + {divider} + {messageComp} + + ); + } + + prevMEvent = mEvent; + const timelineChange = parseTimelineChange(mEvent); + if (timelineChange === null) return null; + return ( + + {divider} + + + ); + } + + return ( +
+
+ { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() } + { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genRoomIntro(undefined, roomTimeline)} + { roomTimeline.timeline.map(renderMessage) } +
+
+ ); +} +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 index 0000000..cfb328c --- /dev/null +++ b/src/app/organisms/room/RoomViewContent.scss @@ -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 index 0000000..56b7a9b --- /dev/null +++ b/src/app/organisms/room/RoomViewFloating.jsx @@ -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 ( + <> +
+
+ {getTypingMessage(typingMembers)} +
+
+ { + timelineScroll.enableSmoothScroll(); + timelineScroll.reachBottom(); + timelineScroll.disableSmoothScroll(); + }} + src={ChevronBottomIC} + tooltip="Scroll to Bottom" + /> +
+ + ); +} +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 index 0000000..501c9f4 --- /dev/null +++ b/src/app/organisms/room/RoomViewFloating.scss @@ -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 index 0000000..d9b8aa9 --- /dev/null +++ b/src/app/organisms/room/RoomViewHeader.jsx @@ -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 ( +
+ + + {roomName} + { typeof roomTopic !== 'undefined' &&

{roomTopic}

} +
+ + ( + <> + Options + {/* */} + { + openInviteUser(roomId); toogleMenu(); + }} + > + Invite + + roomActions.leave(roomId)}>Leave + + )} + render={(toggleMenu) => } + /> +
+ ); +} +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 index 0000000..a72f1e3 --- /dev/null +++ b/src/app/organisms/room/RoomViewInput.jsx @@ -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 ( + <> +
+ + +
+
+ {roomTimeline.isEncryptedRoom() && } + + + timelineScroll.autoReachBottom()} + onKeyDown={handleKeyDown} + placeholder="Send a message..." + /> + + + {isMarkdown && } + +
+
+ { + 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} + /> + +
+ + ); + } + + function attachFile() { + const fileType = attachment.type.slice(0, attachment.type.indexOf('/')); + return ( +
+
+ {fileType === 'image' && {attachment.name}} + {fileType === 'video' && } + {fileType === 'audio' && } + {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && } +
+
+ {attachment.name} + {`size: ${bytesToSize(attachment.size)}`} +
+
+ ); + } + + function attachReply() { + return ( +
+ { + roomsInput.cancelReplyTo(roomId); + setReplyTo(null); + }} + src={CrossIC} + tooltip="Cancel reply" + size="extra-small" + /> + +
+ ); + } + + return ( + <> + { replyTo !== null && attachReply()} + { attachment !== null && attachFile() } +
{ e.preventDefault(); }}> + { + roomTimeline.room.isSpaceRoom() + ? Spaces are yet to be implemented + : renderInputs() + } +
+ + ); +} +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 index 0000000..112a4c4 --- /dev/null +++ b/src/app/organisms/room/RoomViewInput.scss @@ -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 index 0000000..2d876d7 --- /dev/null +++ b/src/app/organisms/room/common.jsx @@ -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 ( + <> + {user} + {' joined the room'} + + ); + }, + leave(user, reason) { + const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; + return ( + <> + {user} + {' left the room'} + {reasonMsg} + + ); + }, + invite(inviter, user) { + return ( + <> + {inviter} + {' invited '} + {user} + + ); + }, + cancelInvite(inviter, user) { + return ( + <> + {inviter} + {' canceled '} + {user} + {'\'s invite'} + + ); + }, + rejectInvite(user) { + return ( + <> + {user} + {' rejected the invitation'} + + ); + }, + kick(actor, user, reason) { + const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; + return ( + <> + {actor} + {' kicked '} + {user} + {reasonMsg} + + ); + }, + ban(actor, user, reason) { + const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; + return ( + <> + {actor} + {' banned '} + {user} + {reasonMsg} + + ); + }, + unban(actor, user) { + return ( + <> + {actor} + {' unbanned '} + {user} + + ); + }, + avatarSets(user) { + return ( + <> + {user} + {' set the avatar'} + + ); + }, + avatarChanged(user) { + return ( + <> + {user} + {' changed the avatar'} + + ); + }, + avatarRemoved(user) { + return ( + <> + {user} + {' removed the avatar'} + + ); + }, + nameSets(user, newName) { + return ( + <> + {user} + {' set the display name to '} + {newName} + + ); + }, + nameChanged(user, newName) { + return ( + <> + {user} + {' changed the display name to '} + {newName} + + ); + }, + nameRemoved(user, lastName) { + return ( + <> + {user} + {' removed the display name '} + {lastName} + + ); + }, + }; +} + +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) => {getUserDisplayName(userId)}; + 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/templates/client/Client.jsx b/src/app/templates/client/Client.jsx index 3d8b45d..8f89d43 100644 --- a/src/app/templates/client/Client.jsx +++ b/src/app/templates/client/Client.jsx @@ -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() {
-
- +
+
diff --git a/src/app/templates/client/Client.scss b/src/app/templates/client/Client.scss index f1d901e..0528098 100644 --- a/src/app/templates/client/Client.scss +++ b/src/app/templates/client/Client.scss @@ -6,7 +6,7 @@ .navigation__wrapper { width: var(--navigation-width); } -.channel__wrapper { +.room__wrapper { flex: 1; min-width: 0; background-color: var(--bg-surface); diff --git a/src/client/action/navigation.js b/src/client/action/navigation.js index 78c001f..cf40b4a 100644 --- a/src/client/action/navigation.js +++ b/src/client/action/navigation.js @@ -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, diff --git a/src/client/state/cons.js b/src/client/state/cons.js index b5de3d6..f5e92b0 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -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', diff --git a/src/client/state/navigation.js b/src/client/state/navigation.js index 1aa6c0c..5c108af 100644 --- a/src/client/state/navigation.js +++ b/src/client/state/navigation.js @@ -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);