+++ /dev/null
-import React from 'react';
-import PropTypes from 'prop-types';
-import './ChannelIntro.scss';
-
-import Linkify from 'linkifyjs/react';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import Avatar from '../../atoms/avatar/Avatar';
-
-function linkifyContent(content) {
- return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
-}
-
-function ChannelIntro({
- roomId, avatarSrc, name, heading, desc, time,
-}) {
- return (
- <div className="channel-intro">
- <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={colorMXID(roomId)} size="large" />
- <div className="channel-intro__content">
- <Text className="channel-intro__name" variant="h1">{heading}</Text>
- <Text className="channel-intro__desc" variant="b1">{linkifyContent(desc)}</Text>
- { time !== null && <Text className="channel-intro__time" variant="b3">{time}</Text>}
- </div>
- </div>
- );
-}
-
-ChannelIntro.defaultProps = {
- avatarSrc: false,
- time: null,
-};
-
-ChannelIntro.propTypes = {
- roomId: PropTypes.string.isRequired,
- avatarSrc: PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.bool,
- ]),
- name: PropTypes.string.isRequired,
- heading: PropTypes.string.isRequired,
- desc: PropTypes.string.isRequired,
- time: PropTypes.string,
-};
-
-export default ChannelIntro;
+++ /dev/null
-.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
+++ /dev/null
-import React from 'react';
-import PropTypes from 'prop-types';
-import './ChannelSelector.scss';
-
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import Avatar from '../../atoms/avatar/Avatar';
-import NotificationBadge from '../../atoms/badge/NotificationBadge';
-import { blurOnBubbling } from '../../atoms/button/script';
-
-function ChannelSelectorWrapper({
- isSelected, onClick, content, options,
-}) {
- return (
- <div className={`channel-selector${isSelected ? ' channel-selector--selected' : ''}`}>
- <button
- className="channel-selector__content"
- type="button"
- onClick={onClick}
- onMouseUp={(e) => blurOnBubbling(e, '.channel-selector')}
- >
- {content}
- </button>
- <div className="channel-selector__options">{options}</div>
- </div>
- );
-}
-ChannelSelectorWrapper.defaultProps = {
- options: null,
-};
-ChannelSelectorWrapper.propTypes = {
- isSelected: PropTypes.bool.isRequired,
- onClick: PropTypes.func.isRequired,
- content: PropTypes.node.isRequired,
- options: PropTypes.node,
-};
-
-function ChannelSelector({
- name, roomId, imageSrc, iconSrc,
- isSelected, isUnread, notificationCount, isAlert,
- options, onClick,
-}) {
- return (
- <ChannelSelectorWrapper
- isSelected={isSelected}
- content={(
- <>
- <Avatar
- text={name.slice(0, 1)}
- bgColor={colorMXID(roomId)}
- imageSrc={imageSrc}
- iconSrc={iconSrc}
- size="extra-small"
- />
- <Text variant="b1">{name}</Text>
- { isUnread && (
- <NotificationBadge
- alert={isAlert}
- content={notificationCount !== 0 ? notificationCount : null}
- />
- )}
- </>
- )}
- options={options}
- onClick={onClick}
- />
- );
-}
-ChannelSelector.defaultProps = {
- imageSrc: null,
- iconSrc: null,
- options: null,
-};
-ChannelSelector.propTypes = {
- name: PropTypes.string.isRequired,
- roomId: PropTypes.string.isRequired,
- imageSrc: PropTypes.string,
- iconSrc: PropTypes.string,
- isSelected: PropTypes.bool.isRequired,
- isUnread: PropTypes.bool.isRequired,
- notificationCount: PropTypes.number.isRequired,
- isAlert: PropTypes.bool.isRequired,
- options: PropTypes.node,
- onClick: PropTypes.func.isRequired,
-};
-
-export default ChannelSelector;
+++ /dev/null
-.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
+++ /dev/null
-import React from 'react';
-import PropTypes from 'prop-types';
-import './ChannelTile.scss';
-
-import Linkify from 'linkifyjs/react';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import Avatar from '../../atoms/avatar/Avatar';
-
-function linkifyContent(content) {
- return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
-}
-
-function ChannelTile({
- avatarSrc, name, id,
- inviterName, memberCount, desc, options,
-}) {
- return (
- <div className="channel-tile">
- <div className="channel-tile__avatar">
- <Avatar
- imageSrc={avatarSrc}
- bgColor={colorMXID(id)}
- text={name.slice(0, 1)}
- />
- </div>
- <div className="channel-tile__content">
- <Text variant="s1">{name}</Text>
- <Text variant="b3">
- {
- inviterName !== null
- ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` • ${memberCount} members`}`
- : id + (memberCount === null ? '' : ` • ${memberCount} members`)
- }
- </Text>
- {
- desc !== null && (typeof desc === 'string')
- ? <Text className="channel-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text>
- : desc
- }
- </div>
- { options !== null && (
- <div className="channel-tile__options">
- {options}
- </div>
- )}
- </div>
- );
-}
-
-ChannelTile.defaultProps = {
- avatarSrc: null,
- inviterName: null,
- options: null,
- desc: null,
- memberCount: null,
-};
-ChannelTile.propTypes = {
- avatarSrc: PropTypes.string,
- name: PropTypes.string.isRequired,
- id: PropTypes.string.isRequired,
- inviterName: PropTypes.string,
- memberCount: PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.number,
- ]),
- desc: PropTypes.node,
- options: PropTypes.node,
-};
-
-export default ChannelTile;
+++ /dev/null
-.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
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+import './RoomIntro.scss';
+
+import Linkify from 'linkifyjs/react';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import Avatar from '../../atoms/avatar/Avatar';
+
+function linkifyContent(content) {
+ return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
+}
+
+function RoomIntro({
+ roomId, avatarSrc, name, heading, desc, time,
+}) {
+ return (
+ <div className="room-intro">
+ <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={colorMXID(roomId)} size="large" />
+ <div className="room-intro__content">
+ <Text className="room-intro__name" variant="h1">{heading}</Text>
+ <Text className="room-intro__desc" variant="b1">{linkifyContent(desc)}</Text>
+ { time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
+ </div>
+ </div>
+ );
+}
+
+RoomIntro.defaultProps = {
+ avatarSrc: false,
+ time: null,
+};
+
+RoomIntro.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ avatarSrc: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.bool,
+ ]),
+ name: PropTypes.string.isRequired,
+ heading: PropTypes.string.isRequired,
+ desc: PropTypes.string.isRequired,
+ time: PropTypes.string,
+};
+
+export default RoomIntro;
--- /dev/null
+.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
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+import './RoomSelector.scss';
+
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import Avatar from '../../atoms/avatar/Avatar';
+import NotificationBadge from '../../atoms/badge/NotificationBadge';
+import { blurOnBubbling } from '../../atoms/button/script';
+
+function RoomSelectorWrapper({
+ isSelected, onClick, content, options,
+}) {
+ return (
+ <div className={`room-selector${isSelected ? ' room-selector--selected' : ''}`}>
+ <button
+ className="room-selector__content"
+ type="button"
+ onClick={onClick}
+ onMouseUp={(e) => blurOnBubbling(e, '.room-selector')}
+ >
+ {content}
+ </button>
+ <div className="room-selector__options">{options}</div>
+ </div>
+ );
+}
+RoomSelectorWrapper.defaultProps = {
+ options: null,
+};
+RoomSelectorWrapper.propTypes = {
+ isSelected: PropTypes.bool.isRequired,
+ onClick: PropTypes.func.isRequired,
+ content: PropTypes.node.isRequired,
+ options: PropTypes.node,
+};
+
+function RoomSelector({
+ name, roomId, imageSrc, iconSrc,
+ isSelected, isUnread, notificationCount, isAlert,
+ options, onClick,
+}) {
+ return (
+ <RoomSelectorWrapper
+ isSelected={isSelected}
+ content={(
+ <>
+ <Avatar
+ text={name.slice(0, 1)}
+ bgColor={colorMXID(roomId)}
+ imageSrc={imageSrc}
+ iconSrc={iconSrc}
+ size="extra-small"
+ />
+ <Text variant="b1">{name}</Text>
+ { isUnread && (
+ <NotificationBadge
+ alert={isAlert}
+ content={notificationCount !== 0 ? notificationCount : null}
+ />
+ )}
+ </>
+ )}
+ options={options}
+ onClick={onClick}
+ />
+ );
+}
+RoomSelector.defaultProps = {
+ imageSrc: null,
+ iconSrc: null,
+ options: null,
+};
+RoomSelector.propTypes = {
+ name: PropTypes.string.isRequired,
+ roomId: PropTypes.string.isRequired,
+ imageSrc: PropTypes.string,
+ iconSrc: PropTypes.string,
+ isSelected: PropTypes.bool.isRequired,
+ isUnread: PropTypes.bool.isRequired,
+ notificationCount: PropTypes.number.isRequired,
+ isAlert: PropTypes.bool.isRequired,
+ options: PropTypes.node,
+ onClick: PropTypes.func.isRequired,
+};
+
+export default RoomSelector;
--- /dev/null
+.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
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+import './RoomTile.scss';
+
+import Linkify from 'linkifyjs/react';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import Avatar from '../../atoms/avatar/Avatar';
+
+function linkifyContent(content) {
+ return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
+}
+
+function RoomTile({
+ avatarSrc, name, id,
+ inviterName, memberCount, desc, options,
+}) {
+ return (
+ <div className="room-tile">
+ <div className="room-tile__avatar">
+ <Avatar
+ imageSrc={avatarSrc}
+ bgColor={colorMXID(id)}
+ text={name.slice(0, 1)}
+ />
+ </div>
+ <div className="room-tile__content">
+ <Text variant="s1">{name}</Text>
+ <Text variant="b3">
+ {
+ inviterName !== null
+ ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` • ${memberCount} members`}`
+ : id + (memberCount === null ? '' : ` • ${memberCount} members`)
+ }
+ </Text>
+ {
+ desc !== null && (typeof desc === 'string')
+ ? <Text className="room-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text>
+ : desc
+ }
+ </div>
+ { options !== null && (
+ <div className="room-tile__options">
+ {options}
+ </div>
+ )}
+ </div>
+ );
+}
+
+RoomTile.defaultProps = {
+ avatarSrc: null,
+ inviterName: null,
+ options: null,
+ desc: null,
+ memberCount: null,
+};
+RoomTile.propTypes = {
+ avatarSrc: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ inviterName: PropTypes.string,
+ memberCount: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ ]),
+ desc: PropTypes.node,
+ options: PropTypes.node,
+};
+
+export default RoomTile;
--- /dev/null
+.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
+++ /dev/null
-import React, { useState, useEffect } from 'react';
-import './Channel.scss';
-
-import cons from '../../../client/state/cons';
-import navigation from '../../../client/state/navigation';
-
-import Welcome from '../welcome/Welcome';
-import ChannelView from './ChannelView';
-import PeopleDrawer from './PeopleDrawer';
-
-function Channel() {
- const [selectedRoomId, changeSelectedRoomId] = useState(null);
- const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible);
- useEffect(() => {
- const handleRoomSelected = (roomId) => {
- changeSelectedRoomId(roomId);
- };
- const handleDrawerToggling = (visiblity) => {
- toggleDrawerVisiblity(visiblity);
- };
- navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
- navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
-
- return () => {
- navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
- navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
- };
- }, []);
-
- if (selectedRoomId === null) return <Welcome />;
-
- return (
- <div className="channel-container">
- <ChannelView roomId={selectedRoomId} />
- { isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
- </div>
- );
-}
-
-export default Channel;
+++ /dev/null
-.channel-container {
- display: flex;
- height: 100%;
-}
\ No newline at end of file
+++ /dev/null
-import React, { useState, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelView.scss';
-
-import EventEmitter from 'events';
-
-import RoomTimeline from '../../../client/state/RoomTimeline';
-
-import ScrollView from '../../atoms/scroll/ScrollView';
-
-import ChannelViewHeader from './ChannelViewHeader';
-import ChannelViewContent from './ChannelViewContent';
-import ChannelViewFloating from './ChannelViewFloating';
-import ChannelViewInput from './ChannelViewInput';
-import ChannelViewCmdBar from './ChannelViewCmdBar';
-
-import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
-
-const viewEvent = new EventEmitter();
-
-let lastScrollTop = 0;
-let lastScrollHeight = 0;
-let isReachedBottom = true;
-let isReachedTop = false;
-function ChannelView({ roomId }) {
- const [roomTimeline, updateRoomTimeline] = useState(null);
- const timelineSVRef = useRef(null);
-
- useEffect(() => {
- roomTimeline?.removeInternalListeners();
- updateRoomTimeline(new RoomTimeline(roomId));
- isReachedBottom = true;
- isReachedTop = false;
- }, [roomId]);
-
- const timelineScroll = {
- reachBottom() {
- scrollToBottom(timelineSVRef);
- },
- autoReachBottom() {
- autoScrollToBottom(timelineSVRef);
- },
- tryRestoringScroll() {
- const sv = timelineSVRef.current;
- const { scrollHeight } = sv;
-
- if (lastScrollHeight === scrollHeight) return;
-
- if (lastScrollHeight < scrollHeight) {
- sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
- } else {
- timelineScroll.reachBottom();
- }
- },
- enableSmoothScroll() {
- timelineSVRef.current.style.scrollBehavior = 'smooth';
- },
- disableSmoothScroll() {
- timelineSVRef.current.style.scrollBehavior = 'auto';
- },
- isScrollable() {
- const oHeight = timelineSVRef.current.offsetHeight;
- const sHeight = timelineSVRef.current.scrollHeight;
- if (sHeight > oHeight) return true;
- return false;
- },
- };
-
- function onTimelineScroll(e) {
- const { scrollTop, scrollHeight, offsetHeight } = e.target;
- const scrollBottom = scrollTop + offsetHeight;
- lastScrollTop = scrollTop;
- lastScrollHeight = scrollHeight;
-
- const PLACEHOLDER_HEIGHT = 96;
- const PLACEHOLDER_COUNT = 3;
-
- const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT;
- const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2);
-
- if (!isReachedBottom && isAtBottom(timelineSVRef)) {
- isReachedBottom = true;
- viewEvent.emit('toggle-reached-bottom', true);
- }
- if (isReachedBottom && !isAtBottom(timelineSVRef)) {
- isReachedBottom = false;
- viewEvent.emit('toggle-reached-bottom', false);
- }
- // TOP of timeline
- if (scrollTop < topPagKeyPoint && isReachedTop === false) {
- isReachedTop = true;
- viewEvent.emit('reached-top');
- return;
- }
- isReachedTop = false;
-
- // BOTTOM of timeline
- if (scrollBottom > bottomPagKeyPoint) {
- // TODO:
- }
- }
-
- return (
- <div className="channel-view">
- <ChannelViewHeader roomId={roomId} />
- <div className="channel-view__content-wrapper">
- <div className="channel-view__scrollable">
- <ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide>
- {roomTimeline !== null && (
- <ChannelViewContent
- roomId={roomId}
- roomTimeline={roomTimeline}
- timelineScroll={timelineScroll}
- viewEvent={viewEvent}
- />
- )}
- </ScrollView>
- {roomTimeline !== null && (
- <ChannelViewFloating
- roomId={roomId}
- roomTimeline={roomTimeline}
- timelineScroll={timelineScroll}
- viewEvent={viewEvent}
- />
- )}
- </div>
- {roomTimeline !== null && (
- <div className="channel-view__sticky">
- <ChannelViewInput
- roomId={roomId}
- roomTimeline={roomTimeline}
- timelineScroll={timelineScroll}
- viewEvent={viewEvent}
- />
- <ChannelViewCmdBar
- roomId={roomId}
- roomTimeline={roomTimeline}
- viewEvent={viewEvent}
- />
- </div>
- )}
- </div>
- </div>
- );
-}
-ChannelView.propTypes = {
- roomId: PropTypes.string.isRequired,
-};
-
-export default ChannelView;
+++ /dev/null
-.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
+++ /dev/null
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewCmdBar.scss';
-import parse from 'html-react-parser';
-import twemoji from 'twemoji';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import { toggleMarkdown } from '../../../client/action/settings';
-import * as roomActions from '../../../client/action/room';
-import {
- selectRoom,
- openCreateChannel,
- openPublicChannels,
- openInviteUser,
- openReadReceipts,
-} from '../../../client/action/navigation';
-import { emojis } from '../emoji-board/emoji';
-import AsyncSearch from '../../../util/AsyncSearch';
-
-import Text from '../../atoms/text/Text';
-import Button from '../../atoms/button/Button';
-import IconButton from '../../atoms/button/IconButton';
-import ContextMenu, { MenuHeader } from '../../atoms/context-menu/ContextMenu';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import SettingTile from '../../molecules/setting-tile/SettingTile';
-import TimelineChange from '../../molecules/message/TimelineChange';
-
-import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
-
-import { getUsersActionJsx } from './common';
-
-const commands = [{
- name: 'markdown',
- description: 'Toggle markdown for messages.',
- exe: () => toggleMarkdown(),
-}, {
- name: 'startDM',
- isOptions: true,
- description: 'Start direct message with user. Example: /startDM/@johndoe.matrix.org',
- exe: (roomId, searchTerm) => openInviteUser(undefined, searchTerm),
-}, {
- name: 'createChannel',
- description: 'Create new channel',
- exe: () => openCreateChannel(),
-}, {
- name: 'join',
- isOptions: true,
- description: 'Join channel with alias. Example: /join/#cinny:matrix.org',
- exe: (roomId, searchTerm) => openPublicChannels(searchTerm),
-}, {
- name: 'leave',
- description: 'Leave current channel',
- exe: (roomId) => roomActions.leave(roomId),
-}, {
- name: 'invite',
- isOptions: true,
- description: 'Invite user to room. Example: /invite/@johndoe:matrix.org',
- exe: (roomId, searchTerm) => openInviteUser(roomId, searchTerm),
-}];
-
-function CmdHelp() {
- return (
- <ContextMenu
- placement="top"
- content={(
- <>
- <MenuHeader>General command</MenuHeader>
- <Text variant="b2">/command_name</Text>
- <MenuHeader>Go-to commands</MenuHeader>
- <Text variant="b2">{'>*space_name'}</Text>
- <Text variant="b2">{'>#channel_name'}</Text>
- <Text variant="b2">{'>@people_name'}</Text>
- <MenuHeader>Autofill command</MenuHeader>
- <Text variant="b2">:emoji_name:</Text>
- <Text variant="b2">@name</Text>
- </>
- )}
- render={(toggleMenu) => (
- <IconButton
- src={CmdIC}
- size="extra-small"
- onClick={toggleMenu}
- tooltip="Commands"
- />
- )}
- />
- );
-}
-
-function ViewCmd() {
- function renderAllCmds() {
- return commands.map((command) => (
- <SettingTile
- key={command.name}
- title={command.name}
- content={(<Text variant="b3">{command.description}</Text>)}
- />
- ));
- }
- return (
- <ContextMenu
- maxWidth={250}
- placement="top"
- content={(
- <>
- <MenuHeader>General commands</MenuHeader>
- {renderAllCmds()}
- </>
- )}
- render={(toggleMenu) => (
- <span>
- <Button onClick={toggleMenu}><span className="text text-b3">View all</span></Button>
- </span>
- )}
- />
- );
-}
-
-function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
- const [followingMembers, setFollowingMembers] = useState([]);
- const mx = initMatrix.matrixClient;
-
- function handleOnMessageSent() {
- setFollowingMembers([]);
- }
-
- function updateFollowingMembers() {
- const room = mx.getRoom(roomId);
- const { timeline } = room;
- const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
- const myUserId = mx.getUserId();
- setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
- }
-
- useEffect(() => updateFollowingMembers(), [roomId]);
-
- useEffect(() => {
- roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
- viewEvent.on('message_sent', handleOnMessageSent);
- return () => {
- roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
- viewEvent.removeListener('message_sent', handleOnMessageSent);
- };
- }, [roomTimeline]);
-
- const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1];
- return followingMembers.length !== 0 && (
- <TimelineChange
- variant="follow"
- content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')}
- time=""
- onClick={() => openReadReceipts(roomId, lastMEvent.getId())}
- />
- );
-}
-
-FollowingMembers.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- viewEvent: PropTypes.shape({}).isRequired,
-};
-
-function getCmdActivationMessage(prefix) {
- function genMessage(prime, secondary) {
- return (
- <>
- <span>{prime}</span>
- <span>{secondary}</span>
- </>
- );
- }
- const cmd = {
- '/': () => genMessage('General command mode activated. ', 'Type command name for suggestions.'),
- '>*': () => genMessage('Go-to command mode activated. ', 'Type space name for suggestions.'),
- '>#': () => genMessage('Go-to command mode activated. ', 'Type channel name for suggestions.'),
- '>@': () => genMessage('Go-to command mode activated. ', 'Type people name for suggestions.'),
- ':': () => genMessage('Emoji autofill command mode activated. ', 'Type emoji shortcut for suggestions.'),
- '@': () => genMessage('Name autofill command mode activated. ', 'Type name for suggestions.'),
- };
- return cmd[prefix]?.();
-}
-
-function CmdItem({ onClick, children }) {
- return (
- <button className="cmd-item" onClick={onClick} type="button">
- {children}
- </button>
- );
-}
-CmdItem.propTypes = {
- onClick: PropTypes.func.isRequired,
- children: PropTypes.node.isRequired,
-};
-
-function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
- function getGenCmdSuggestions(cmdPrefix, cmds) {
- const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?';
- return cmds.map((cmd) => (
- <CmdItem
- key={cmd.name}
- onClick={() => {
- fireCmd({
- prefix: cmdPrefix,
- option,
- result: cmd,
- });
- }}
- >
- <Text variant="b2">{`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`}</Text>
- </CmdItem>
- ));
- }
-
- function getRoomsSuggestion(cmdPrefix, rooms) {
- return rooms.map((room) => (
- <CmdItem
- key={room.roomId}
- onClick={() => {
- fireCmd({
- prefix: cmdPrefix,
- result: room,
- });
- }}
- >
- <Text variant="b2">{room.name}</Text>
- </CmdItem>
- ));
- }
-
- function getEmojiSuggestion(emPrefix, emos) {
- return emos.map((emoji) => (
- <CmdItem
- key={emoji.hexcode}
- onClick={() => fireCmd({
- prefix: emPrefix,
- result: emoji,
- })}
- >
- {
- parse(twemoji.parse(
- emoji.unicode,
- {
- attributes: () => ({
- unicode: emoji.unicode,
- shortcodes: emoji.shortcodes?.toString(),
- }),
- },
- ))
- }
- <Text variant="b2">{`:${emoji.shortcode}:`}</Text>
- </CmdItem>
- ));
- }
-
- function getNameSuggestion(namePrefix, members) {
- return members.map((member) => (
- <CmdItem
- key={member.userId}
- onClick={() => {
- fireCmd({
- prefix: namePrefix,
- result: member,
- });
- }}
- >
- <Text variant="b2">{member.name}</Text>
- </CmdItem>
- ));
- }
-
- const cmd = {
- '/': (cmds) => getGenCmdSuggestions(prefix, cmds),
- '>*': (spaces) => getRoomsSuggestion(prefix, spaces),
- '>#': (channels) => getRoomsSuggestion(prefix, channels),
- '>@': (peoples) => getRoomsSuggestion(prefix, peoples),
- ':': (emos) => getEmojiSuggestion(prefix, emos),
- '@': (members) => getNameSuggestion(prefix, members),
- };
- return cmd[prefix]?.(suggestions);
-}
-
-const asyncSearch = new AsyncSearch();
-let cmdPrefix;
-let cmdOption;
-function ChannelViewCmdBar({ roomId, roomTimeline, viewEvent }) {
- const [cmd, setCmd] = useState(null);
-
- function displaySuggestions(suggestions) {
- if (suggestions.length === 0) {
- setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
- viewEvent.emit('cmd_error');
- return;
- }
- setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
- }
-
- function processCmd(prefix, slug) {
- let searchTerm = slug;
- cmdOption = undefined;
- cmdPrefix = prefix;
- if (prefix === '/') {
- const cmdSlugParts = slug.split('/');
- [searchTerm, cmdOption] = cmdSlugParts;
- }
- if (prefix === ':') {
- if (searchTerm.length <= 3) {
- if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
- else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
- else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
- else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
- else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
- else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
- else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
- else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
- else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
- else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
- else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
- else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
- }
- }
-
- asyncSearch.search(searchTerm);
- }
- function activateCmd(prefix) {
- setCmd({ prefix });
- cmdPrefix = prefix;
-
- const { roomList, matrixClient } = initMatrix;
- function getRooms(roomIds) {
- return roomIds.map((rId) => {
- const room = matrixClient.getRoom(rId);
- return {
- name: room.name,
- roomId: room.roomId,
- };
- });
- }
- const setupSearch = {
- '/': () => asyncSearch.setup(commands, { keys: ['name'], isContain: true }),
- '>*': () => asyncSearch.setup(getRooms([...roomList.spaces]), { keys: ['name'], limit: 20 }),
- '>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }),
- '>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { keys: ['name'], limit: 20 }),
- ':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 20 }),
- '@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({
- name: member.name,
- userId: member.userId.slice(1),
- })), { keys: ['name', 'userId'], limit: 20 }),
- };
- setupSearch[prefix]?.();
- }
- function deactivateCmd() {
- setCmd(null);
- cmdOption = undefined;
- cmdPrefix = undefined;
- }
- function fireCmd(myCmd) {
- if (myCmd.prefix.match(/^>[*#@]$/)) {
- selectRoom(myCmd.result.roomId);
- viewEvent.emit('cmd_fired');
- }
- if (myCmd.prefix === '/') {
- myCmd.result.exe(roomId, myCmd.option);
- viewEvent.emit('cmd_fired');
- }
- if (myCmd.prefix === ':') {
- viewEvent.emit('cmd_fired', {
- replace: myCmd.result.unicode,
- });
- }
- if (myCmd.prefix === '@') {
- viewEvent.emit('cmd_fired', {
- replace: myCmd.result.name,
- });
- }
- deactivateCmd();
- }
- function executeCmd() {
- if (cmd.suggestions.length === 0) return;
- fireCmd({
- prefix: cmd.prefix,
- option: cmd.option,
- result: cmd.suggestions[0],
- });
- }
-
- function listenKeyboard(event) {
- const { activeElement } = document;
- const lastCmdItem = document.activeElement.parentNode.lastElementChild;
- if (event.keyCode === 27) {
- if (activeElement.className !== 'cmd-item') return;
- viewEvent.emit('focus_msg_input');
- }
- if (event.keyCode === 9) {
- if (lastCmdItem.className !== 'cmd-item') return;
- if (lastCmdItem !== activeElement) return;
- if (event.shiftKey) return;
- viewEvent.emit('focus_msg_input');
- event.preventDefault();
- }
- }
-
- useEffect(() => {
- viewEvent.on('cmd_activate', activateCmd);
- viewEvent.on('cmd_deactivate', deactivateCmd);
- return () => {
- deactivateCmd();
- viewEvent.removeListener('cmd_activate', activateCmd);
- viewEvent.removeListener('cmd_deactivate', deactivateCmd);
- };
- }, [roomId]);
-
- useEffect(() => {
- if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
- viewEvent.on('cmd_process', processCmd);
- viewEvent.on('cmd_exe', executeCmd);
- asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
- return () => {
- if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
-
- viewEvent.removeListener('cmd_process', processCmd);
- viewEvent.removeListener('cmd_exe', executeCmd);
- asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
- };
- }, [cmd]);
-
- if (typeof cmd?.error === 'string') {
- return (
- <div className="cmd-bar">
- <div className="cmd-bar__info">
- <div className="cmd-bar__info-indicator--error" />
- </div>
- <div className="cmd-bar__content">
- <Text className="cmd-bar__content-error" variant="b2">{cmd.error}</Text>
- </div>
- </div>
- );
- }
-
- return (
- <div className="cmd-bar">
- <div className="cmd-bar__info">
- {cmd === null && <CmdHelp />}
- {cmd !== null && typeof cmd.suggestions === 'undefined' && <div className="cmd-bar__info-indicator" /> }
- {cmd !== null && typeof cmd.suggestions !== 'undefined' && <Text variant="b3">TAB</Text>}
- </div>
- <div className="cmd-bar__content">
- {cmd === null && (
- <FollowingMembers
- roomId={roomId}
- roomTimeline={roomTimeline}
- viewEvent={viewEvent}
- />
- )}
- {cmd !== null && typeof cmd.suggestions === 'undefined' && <Text className="cmd-bar__content-help" variant="b2">{getCmdActivationMessage(cmd.prefix)}</Text>}
- {cmd !== null && typeof cmd.suggestions !== 'undefined' && (
- <ScrollView horizontal vertical={false} invisible>
- <div className="cmd-bar__content__suggestions">{getCmdSuggestions(cmd, fireCmd)}</div>
- </ScrollView>
- )}
- </div>
- <div className="cmd-bar__more">
- {cmd !== null && cmd.prefix === '/' && <ViewCmd />}
- </div>
- </div>
- );
-}
-ChannelViewCmdBar.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewCmdBar;
+++ /dev/null
-.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
+++ /dev/null
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect, useLayoutEffect } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewContent.scss';
-
-import dateFormat from 'dateformat';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
-import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil';
-import colorMXID from '../../../util/colorMXID';
-import { diffMinutes, isNotInSameDay } from '../../../util/common';
-import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation';
-
-import Divider from '../../atoms/divider/Divider';
-import Avatar from '../../atoms/avatar/Avatar';
-import IconButton from '../../atoms/button/IconButton';
-import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
-import {
- Message,
- MessageHeader,
- MessageReply,
- MessageContent,
- MessageEdit,
- MessageReactionGroup,
- MessageReaction,
- MessageOptions,
- PlaceholderMessage,
-} from '../../molecules/message/Message';
-import * as Media from '../../molecules/media/Media';
-import ChannelIntro from '../../molecules/channel-intro/ChannelIntro';
-import TimelineChange from '../../molecules/message/TimelineChange';
-
-import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
-import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
-import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
-import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
-import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
-import BinIC from '../../../../public/res/ic/outlined/bin.svg';
-
-import { parseReply, parseTimelineChange } from './common';
-
-const MAX_MSG_DIFF_MINUTES = 5;
-
-function genPlaceholders() {
- return (
- <>
- <PlaceholderMessage key="placeholder-1" />
- <PlaceholderMessage key="placeholder-2" />
- <PlaceholderMessage key="placeholder-3" />
- </>
- );
-}
-
-function isMedia(mE) {
- return (
- mE.getContent()?.msgtype === 'm.file'
- || mE.getContent()?.msgtype === 'm.image'
- || mE.getContent()?.msgtype === 'm.audio'
- || mE.getContent()?.msgtype === 'm.video'
- || mE.getType() === 'm.sticker'
- );
-}
-
-function genMediaContent(mE) {
- const mx = initMatrix.matrixClient;
- const mContent = mE.getContent();
- if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
-
- let mediaMXC = mContent?.url;
- const isEncryptedFile = typeof mediaMXC === 'undefined';
- if (isEncryptedFile) mediaMXC = mContent?.file?.url;
-
- let thumbnailMXC = mContent?.info?.thumbnail_url;
-
- if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
-
- let msgType = mE.getContent()?.msgtype;
- if (mE.getType() === 'm.sticker') msgType = 'm.image';
-
- switch (msgType) {
- case 'm.file':
- return (
- <Media.File
- name={mContent.body}
- link={mx.mxcUrlToHttp(mediaMXC)}
- type={mContent.info?.mimetype}
- file={mContent.file || null}
- />
- );
- case 'm.image':
- return (
- <Media.Image
- name={mContent.body}
- width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
- height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
- link={mx.mxcUrlToHttp(mediaMXC)}
- file={isEncryptedFile ? mContent.file : null}
- type={mContent.info?.mimetype}
- />
- );
- case 'm.audio':
- return (
- <Media.Audio
- name={mContent.body}
- link={mx.mxcUrlToHttp(mediaMXC)}
- type={mContent.info?.mimetype}
- file={mContent.file || null}
- />
- );
- case 'm.video':
- if (typeof thumbnailMXC === 'undefined') {
- thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
- }
- return (
- <Media.Video
- name={mContent.body}
- link={mx.mxcUrlToHttp(mediaMXC)}
- thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
- thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
- thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
- width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
- height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
- file={isEncryptedFile ? mContent.file : null}
- type={mContent.info?.mimetype}
- />
- );
- default:
- return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
- }
-}
-
-function genChannelIntro(mEvent, roomTimeline) {
- const mx = initMatrix.matrixClient;
- const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
- const isDM = initMatrix.roomList.directs.has(roomTimeline.roomId);
- let avatarSrc = roomTimeline.room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
- avatarSrc = isDM ? roomTimeline.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
- return (
- <ChannelIntro
- key={mEvent ? mEvent.getId() : 'channel-intro'}
- roomId={roomTimeline.roomId}
- avatarSrc={avatarSrc}
- name={roomTimeline.room.name}
- heading={`Welcome to ${roomTimeline.room.name}`}
- desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
- time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
- />
- );
-}
-
-function getMyEmojiEventId(emojiKey, eventId, roomTimeline) {
- const mx = initMatrix.matrixClient;
- const rEvents = roomTimeline.reactionTimeline.get(eventId);
- let rEventId = null;
- rEvents?.find((rE) => {
- if (rE.getRelation() === null) return false;
- if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
- rEventId = rE.getId();
- return true;
- }
- return false;
- });
- return rEventId;
-}
-
-function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
- const myAlreadyReactEventId = getMyEmojiEventId(emojiKey, eventId, roomTimeline);
- if (typeof myAlreadyReactEventId === 'string') {
- if (myAlreadyReactEventId.indexOf('~') === 0) return;
- redactEvent(roomId, myAlreadyReactEventId);
- return;
- }
- sendReaction(roomId, eventId, emojiKey);
-}
-
-function pickEmoji(e, roomId, eventId, roomTimeline) {
- const boxInfo = e.target.getBoundingClientRect();
- openEmojiBoard({
- x: boxInfo.x,
- y: boxInfo.y,
- detail: e.detail,
- }, (emoji) => {
- toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
- e.target.click();
- });
-}
-
-let wasAtBottom = true;
-function ChannelViewContent({
- roomId, roomTimeline, timelineScroll, viewEvent,
-}) {
- const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
- const [onStateUpdate, updateState] = useState(null);
- const [onPagination, setOnPagination] = useState(null);
- const [editEvent, setEditEvent] = useState(null);
- const mx = initMatrix.matrixClient;
-
- function autoLoadTimeline() {
- if (timelineScroll.isScrollable() === true) return;
- roomTimeline.paginateBack();
- }
- function trySendingReadReceipt() {
- const { room, timeline } = roomTimeline;
- if (doesRoomHaveUnread(room) && timeline.length !== 0) {
- mx.sendReadReceipt(timeline[timeline.length - 1]);
- }
- }
-
- function onReachedTop() {
- if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
- roomTimeline.paginateBack();
- }
- function toggleOnReachedBottom(isBottom) {
- wasAtBottom = isBottom;
- if (!isBottom) return;
- trySendingReadReceipt();
- }
-
- const updatePAG = (canPagMore) => {
- if (!canPagMore) {
- setIsReachedTimelineEnd(true);
- } else {
- setOnPagination({});
- autoLoadTimeline();
- }
- };
- // force update RoomTimeline on cons.events.roomTimeline.EVENT
- const updateRT = () => {
- if (wasAtBottom) {
- trySendingReadReceipt();
- }
- updateState({});
- };
-
- useEffect(() => {
- setIsReachedTimelineEnd(false);
- wasAtBottom = true;
- }, [roomId]);
- useEffect(() => trySendingReadReceipt(), [roomTimeline]);
-
- // init room setup completed.
- // listen for future. setup stateUpdate listener.
- useEffect(() => {
- roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
- roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
- viewEvent.on('reached-top', onReachedTop);
- viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom);
-
- return () => {
- roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
- roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
- viewEvent.removeListener('reached-top', onReachedTop);
- viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom);
- };
- }, [roomTimeline, isReachedTimelineEnd, onPagination]);
-
- useLayoutEffect(() => {
- timelineScroll.reachBottom();
- autoLoadTimeline();
- }, [roomTimeline]);
-
- useLayoutEffect(() => {
- if (onPagination === null) return;
- timelineScroll.tryRestoringScroll();
- }, [onPagination]);
-
- useEffect(() => {
- if (onStateUpdate === null) return;
- if (wasAtBottom) timelineScroll.reachBottom();
- }, [onStateUpdate]);
-
- let prevMEvent = null;
- function genMessage(mEvent) {
- const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel;
- const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
-
- const isContentOnly = (
- prevMEvent !== null
- && prevMEvent.getType() !== 'm.room.member'
- && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
- && prevMEvent.getSender() === mEvent.getSender()
- );
-
- let content = mEvent.getContent().body;
- if (typeof content === 'undefined') return null;
- let reply = null;
- let reactions = null;
- let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html';
- const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
- const isEdited = roomTimeline.editedTimeline.has(mEvent.getId());
- const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId());
-
- if (isReply) {
- const parsedContent = parseReply(content);
- if (parsedContent !== null) {
- const c = roomTimeline.room.currentState;
- const displayNameToUserIds = c.getUserIdsWithDisplayName(parsedContent.displayName);
- const ID = parsedContent.userId || displayNameToUserIds[0];
- reply = {
- color: colorMXID(ID || parsedContent.displayName),
- to: parsedContent.displayName || getUsername(parsedContent.userId),
- content: parsedContent.replyContent,
- };
- content = parsedContent.content;
- }
- }
-
- if (isEdited) {
- const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
- const latestEdited = editedList[editedList.length - 1];
- if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null;
- const latestEditBody = latestEdited.getContent()['m.new_content'].body;
- const parsedEditedContent = parseReply(latestEditBody);
- isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html';
- if (parsedEditedContent === null) {
- content = latestEditBody;
- } else {
- content = parsedEditedContent.content;
- }
- }
-
- if (haveReactions) {
- reactions = [];
- roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => {
- if (rEvent.getRelation() === null) return;
- function alreadyHaveThisReaction(rE) {
- for (let i = 0; i < reactions.length; i += 1) {
- if (reactions[i].key === rE.getRelation().key) return true;
- }
- return false;
- }
- if (alreadyHaveThisReaction(rEvent)) {
- for (let i = 0; i < reactions.length; i += 1) {
- if (reactions[i].key === rEvent.getRelation().key) {
- reactions[i].users.push(rEvent.getSender());
- if (reactions[i].isActive !== true) {
- const myUserId = initMatrix.matrixClient.getUserId();
- reactions[i].isActive = rEvent.getSender() === myUserId;
- if (reactions[i].isActive) reactions[i].id = rEvent.getId();
- }
- break;
- }
- }
- } else {
- reactions.push({
- id: rEvent.getId(),
- key: rEvent.getRelation().key,
- users: [rEvent.getSender()],
- isActive: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
- });
- }
- });
- }
-
- const senderMXIDColor = colorMXID(mEvent.sender.userId);
- const userAvatar = isContentOnly ? null : (
- <Avatar
- imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
- text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
- bgColor={senderMXIDColor}
- size="small"
- />
- );
- const userHeader = isContentOnly ? null : (
- <MessageHeader
- userId={mEvent.sender.userId}
- name={getUsernameOfRoomMember(mEvent.sender)}
- color={senderMXIDColor}
- time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
- />
- );
- const userReply = reply === null ? null : (
- <MessageReply
- name={reply.to}
- color={reply.color}
- content={reply.content}
- />
- );
- const userContent = (
- <MessageContent
- isMarkdown={isMarkdown}
- content={isMedia(mEvent) ? genMediaContent(mEvent) : content}
- isEdited={isEdited}
- />
- );
- const userReactions = reactions === null ? null : (
- <MessageReactionGroup>
- {
- reactions.map((reaction) => (
- <MessageReaction
- key={reaction.id}
- reaction={reaction.key}
- users={reaction.users}
- isActive={reaction.isActive}
- onClick={() => {
- toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline);
- }}
- />
- ))
- }
- <IconButton
- onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
- src={EmojiAddIC}
- size="extra-small"
- tooltip="Add reaction"
- />
- </MessageReactionGroup>
- );
- const userOptions = (
- <MessageOptions>
- <IconButton
- onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
- src={EmojiAddIC}
- size="extra-small"
- tooltip="Add reaction"
- />
- <IconButton
- onClick={() => {
- viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
- }}
- src={ReplyArrowIC}
- size="extra-small"
- tooltip="Reply"
- />
- {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
- <IconButton
- onClick={() => setEditEvent(mEvent)}
- src={PencilIC}
- size="extra-small"
- tooltip="Edit"
- />
- )}
- <ContextMenu
- content={() => (
- <>
- <MenuHeader>Options</MenuHeader>
- <MenuItem
- iconSrc={EmojiAddIC}
- onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
- >
- Add reaction
- </MenuItem>
- <MenuItem
- iconSrc={ReplyArrowIC}
- onClick={() => {
- viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
- }}
- >
- Reply
- </MenuItem>
- {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
- <MenuItem iconSrc={PencilIC} onClick={() => setEditEvent(mEvent)}>Edit</MenuItem>
- )}
- <MenuItem
- iconSrc={TickMarkIC}
- onClick={() => openReadReceipts(roomId, mEvent.getId())}
- >
- Read receipts
- </MenuItem>
- {(canIRedact || mEvent.getSender() === mx.getUserId()) && (
- <>
- <MenuBorder />
- <MenuItem
- variant="danger"
- iconSrc={BinIC}
- onClick={() => {
- if (window.confirm('Are you sure you want to delete this event')) {
- redactEvent(roomId, mEvent.getId());
- }
- }}
- >
- Delete
- </MenuItem>
- </>
- )}
- </>
- )}
- render={(toggleMenu) => (
- <IconButton
- onClick={toggleMenu}
- src={VerticalMenuIC}
- size="extra-small"
- tooltip="Options"
- />
- )}
- />
- </MessageOptions>
- );
-
- const isEditingEvent = editEvent?.getId() === mEvent.getId();
- const myMessageEl = (
- <Message
- key={mEvent.getId()}
- avatar={userAvatar}
- header={userHeader}
- reply={userReply}
- content={editEvent !== null && isEditingEvent ? null : userContent}
- editContent={editEvent !== null && isEditingEvent ? (
- <MessageEdit
- content={content}
- onSave={(newBody) => {
- if (newBody !== content) {
- initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
- }
- setEditEvent(null);
- }}
- onCancel={() => setEditEvent(null)}
- />
- ) : null}
- reactions={userReactions}
- options={editEvent !== null && isEditingEvent ? null : userOptions}
- />
- );
- return myMessageEl;
- }
-
- function renderMessage(mEvent) {
- if (mEvent.getType() === 'm.room.create') return genChannelIntro(mEvent, roomTimeline);
- if (
- mEvent.getType() !== 'm.room.message'
- && mEvent.getType() !== 'm.room.encrypted'
- && mEvent.getType() !== 'm.room.member'
- && mEvent.getType() !== 'm.sticker'
- ) return false;
- if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
-
- // ignore if message is deleted
- if (mEvent.isRedacted()) return false;
-
- let divider = null;
- if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
- divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
- }
-
- if (mEvent.getType() !== 'm.room.member') {
- const messageComp = genMessage(mEvent);
- prevMEvent = mEvent;
- return (
- <React.Fragment key={`box-${mEvent.getId()}`}>
- {divider}
- {messageComp}
- </React.Fragment>
- );
- }
-
- prevMEvent = mEvent;
- const timelineChange = parseTimelineChange(mEvent);
- if (timelineChange === null) return null;
- return (
- <React.Fragment key={`box-${mEvent.getId()}`}>
- {divider}
- <TimelineChange
- key={mEvent.getId()}
- variant={timelineChange.variant}
- content={timelineChange.content}
- time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
- />
- </React.Fragment>
- );
- }
-
- return (
- <div className="channel-view__content">
- <div className="timeline__wrapper">
- { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() }
- { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genChannelIntro(undefined, roomTimeline)}
- { roomTimeline.timeline.map(renderMessage) }
- </div>
- </div>
- );
-}
-ChannelViewContent.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({}).isRequired,
- viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewContent;
+++ /dev/null
-.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
+++ /dev/null
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewFloating.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-
-import Text from '../../atoms/text/Text';
-import IconButton from '../../atoms/button/IconButton';
-
-import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
-
-import { getUsersActionJsx } from './common';
-
-function ChannelViewFloating({
- roomId, roomTimeline, timelineScroll, viewEvent,
-}) {
- const [reachedBottom, setReachedBottom] = useState(true);
- const [typingMembers, setTypingMembers] = useState(new Set());
- const mx = initMatrix.matrixClient;
-
- function isSomeoneTyping(members) {
- const m = members;
- m.delete(mx.getUserId());
- if (m.size === 0) return false;
- return true;
- }
-
- function getTypingMessage(members) {
- const userIds = members;
- userIds.delete(mx.getUserId());
- return getUsersActionJsx(roomId, [...userIds], 'typing...');
- }
-
- function updateTyping(members) {
- setTypingMembers(members);
- }
-
- useEffect(() => {
- setReachedBottom(true);
- setTypingMembers(new Set());
- viewEvent.on('toggle-reached-bottom', setReachedBottom);
- return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
- }, [roomId]);
-
- useEffect(() => {
- roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
- return () => {
- roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
- };
- }, [roomTimeline]);
-
- return (
- <>
- <div className={`channel-view__typing${isSomeoneTyping(typingMembers) ? ' channel-view__typing--open' : ''}`}>
- <div className="bouncingLoader"><div /></div>
- <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
- </div>
- <div className={`channel-view__STB${reachedBottom ? '' : ' channel-view__STB--open'}`}>
- <IconButton
- onClick={() => {
- timelineScroll.enableSmoothScroll();
- timelineScroll.reachBottom();
- timelineScroll.disableSmoothScroll();
- }}
- src={ChevronBottomIC}
- tooltip="Scroll to Bottom"
- />
- </div>
- </>
- );
-}
-ChannelViewFloating.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({
- reachBottom: PropTypes.func,
- }).isRequired,
- viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewFloating;
+++ /dev/null
-.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
+++ /dev/null
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import initMatrix from '../../../client/initMatrix';
-import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation';
-import * as roomActions from '../../../client/action/room';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import IconButton from '../../atoms/button/IconButton';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import Avatar from '../../atoms/avatar/Avatar';
-import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
-
-import UserIC from '../../../../public/res/ic/outlined/user.svg';
-import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
-import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
-import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
-
-function ChannelViewHeader({ roomId }) {
- const mx = initMatrix.matrixClient;
- const isDM = initMatrix.roomList.directs.has(roomId);
- let avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
- avatarSrc = isDM ? mx.getRoom(roomId).getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop') : avatarSrc;
- const roomName = mx.getRoom(roomId).name;
- const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
-
- return (
- <Header>
- <Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomId)} size="small" />
- <TitleWrapper>
- <Text variant="h2">{roomName}</Text>
- { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
- </TitleWrapper>
- <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
- <ContextMenu
- placement="bottom"
- content={(toogleMenu) => (
- <>
- <MenuHeader>Options</MenuHeader>
- {/* <MenuBorder /> */}
- <MenuItem
- iconSrc={AddUserIC}
- onClick={() => {
- openInviteUser(roomId); toogleMenu();
- }}
- >
- Invite
- </MenuItem>
- <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId)}>Leave</MenuItem>
- </>
- )}
- render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
- />
- </Header>
- );
-}
-ChannelViewHeader.propTypes = {
- roomId: PropTypes.string.isRequired,
-};
-
-export default ChannelViewHeader;
+++ /dev/null
-/* eslint-disable react/prop-types */
-import React, { useState, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './ChannelViewInput.scss';
-
-import TextareaAutosize from 'react-autosize-textarea';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import settings from '../../../client/state/settings';
-import { openEmojiBoard } from '../../../client/action/navigation';
-import { bytesToSize } from '../../../util/common';
-import { getUsername } from '../../../util/matrixUtil';
-import colorMXID from '../../../util/colorMXID';
-
-import Text from '../../atoms/text/Text';
-import RawIcon from '../../atoms/system-icons/RawIcon';
-import IconButton from '../../atoms/button/IconButton';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import { MessageReply } from '../../molecules/message/Message';
-
-import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
-import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
-import SendIC from '../../../../public/res/ic/outlined/send.svg';
-import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
-import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
-import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
-import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg';
-import FileIC from '../../../../public/res/ic/outlined/file.svg';
-import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-
-const CMD_REGEX = /(\/|>[#*@]|:|@)(\S*)$/;
-let isTyping = false;
-let isCmdActivated = false;
-let cmdCursorPos = null;
-function ChannelViewInput({
- roomId, roomTimeline, timelineScroll, viewEvent,
-}) {
- const [attachment, setAttachment] = useState(null);
- const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
- const [replyTo, setReplyTo] = useState(null);
-
- const textAreaRef = useRef(null);
- const inputBaseRef = useRef(null);
- const uploadInputRef = useRef(null);
- const uploadProgressRef = useRef(null);
- const rightOptionsRef = useRef(null);
- const escBtnRef = useRef(null);
-
- const TYPING_TIMEOUT = 5000;
- const mx = initMatrix.matrixClient;
- const { roomsInput } = initMatrix;
-
- function requestFocusInput() {
- if (textAreaRef === null) return;
- textAreaRef.current.focus();
- }
-
- useEffect(() => {
- settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
- viewEvent.on('focus_msg_input', requestFocusInput);
- return () => {
- settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
- viewEvent.removeListener('focus_msg_input', requestFocusInput);
- };
- }, []);
-
- const sendIsTyping = (isT) => {
- mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
- isTyping = isT;
-
- if (isT === true) {
- setTimeout(() => {
- if (isTyping) sendIsTyping(false);
- }, TYPING_TIMEOUT);
- }
- };
-
- function uploadingProgress(myRoomId, { loaded, total }) {
- if (myRoomId !== roomId) return;
- const progressPer = Math.round((loaded * 100) / total);
- uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
- inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
- }
- function clearAttachment(myRoomId) {
- if (roomId !== myRoomId) return;
- setAttachment(null);
- inputBaseRef.current.style.backgroundImage = 'unset';
- uploadInputRef.current.value = null;
- }
-
- function rightOptionsA11Y(A11Y) {
- const rightOptions = rightOptionsRef.current.children;
- for (let index = 0; index < rightOptions.length; index += 1) {
- rightOptions[index].disabled = !A11Y;
- }
- }
-
- function activateCmd(prefix) {
- isCmdActivated = true;
- requestAnimationFrame(() => {
- inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-positive)';
- escBtnRef.current.style.display = 'block';
- });
- rightOptionsA11Y(false);
- viewEvent.emit('cmd_activate', prefix);
- }
- function deactivateCmd() {
- if (inputBaseRef.current !== null) {
- requestAnimationFrame(() => {
- inputBaseRef.current.style.boxShadow = 'var(--bs-surface-border)';
- escBtnRef.current.style.display = 'none';
- });
- rightOptionsA11Y(true);
- }
- isCmdActivated = false;
- cmdCursorPos = null;
- }
- function deactivateCmdAndEmit() {
- deactivateCmd();
- viewEvent.emit('cmd_deactivate');
- }
- function errorCmd() {
- requestAnimationFrame(() => {
- inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-danger)';
- });
- }
- function setCursorPosition(pos) {
- setTimeout(() => {
- textAreaRef.current.focus();
- textAreaRef.current.setSelectionRange(pos, pos);
- }, 0);
- }
- function replaceCmdWith(msg, cursor, replacement) {
- if (msg === null) return null;
- const targetInput = msg.slice(0, cursor);
- const cmdParts = targetInput.match(CMD_REGEX);
- const leadingInput = msg.slice(0, cmdParts.index);
- if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
- return leadingInput + replacement + msg.slice(cursor);
- }
- function firedCmd(cmdData) {
- const msg = textAreaRef.current.value;
- textAreaRef.current.value = replaceCmdWith(
- msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
- );
- deactivateCmd();
- }
-
- function focusInput() {
- if (settings.isTouchScreenDevice) return;
- textAreaRef.current.focus();
- }
-
- function setUpReply(userId, eventId, content) {
- setReplyTo({ userId, eventId, content });
- roomsInput.setReplyTo(roomId, { userId, eventId, content });
- focusInput();
- }
-
- useEffect(() => {
- roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
- roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
- roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
- viewEvent.on('cmd_error', errorCmd);
- viewEvent.on('cmd_fired', firedCmd);
- viewEvent.on('reply_to', setUpReply);
- if (textAreaRef?.current !== null) {
- isTyping = false;
- focusInput();
- textAreaRef.current.value = roomsInput.getMessage(roomId);
- setAttachment(roomsInput.getAttachment(roomId));
- setReplyTo(roomsInput.getReplyTo(roomId));
- }
- return () => {
- roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
- roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
- roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
- viewEvent.removeListener('cmd_error', errorCmd);
- viewEvent.removeListener('cmd_fired', firedCmd);
- viewEvent.removeListener('reply_to', setUpReply);
- if (isCmdActivated) deactivateCmd();
- if (textAreaRef?.current === null) return;
-
- const msg = textAreaRef.current.value;
- inputBaseRef.current.style.backgroundImage = 'unset';
- if (msg.trim() === '') {
- roomsInput.setMessage(roomId, '');
- return;
- }
- roomsInput.setMessage(roomId, msg);
- };
- }, [roomId]);
-
- async function sendMessage() {
- const msgBody = textAreaRef.current.value;
- if (roomsInput.isSending(roomId)) return;
- if (msgBody.trim() === '' && attachment === null) return;
- sendIsTyping(false);
-
- roomsInput.setMessage(roomId, msgBody);
- if (attachment !== null) {
- roomsInput.setAttachment(roomId, attachment);
- }
- textAreaRef.current.disabled = true;
- textAreaRef.current.style.cursor = 'not-allowed';
- await roomsInput.sendInput(roomId);
- textAreaRef.current.disabled = false;
- textAreaRef.current.style.cursor = 'unset';
- focusInput();
-
- textAreaRef.current.value = roomsInput.getMessage(roomId);
- timelineScroll.reachBottom();
- viewEvent.emit('message_sent');
- textAreaRef.current.style.height = 'unset';
- if (replyTo !== null) setReplyTo(null);
- }
-
- function processTyping(msg) {
- const isEmptyMsg = msg === '';
-
- if (isEmptyMsg && isTyping) {
- sendIsTyping(false);
- return;
- }
- if (!isEmptyMsg && !isTyping) {
- sendIsTyping(true);
- }
- }
-
- function getCursorPosition() {
- return textAreaRef.current.selectionStart;
- }
-
- function recognizeCmd(rawInput) {
- const cursor = getCursorPosition();
- const targetInput = rawInput.slice(0, cursor);
-
- const cmdParts = targetInput.match(CMD_REGEX);
- if (cmdParts === null) {
- if (isCmdActivated) deactivateCmdAndEmit();
- return;
- }
- const cmdPrefix = cmdParts[1];
- const cmdSlug = cmdParts[2];
-
- if (cmdPrefix === ':') {
- // skip emoji autofill command if link is suspected.
- const checkForLink = targetInput.slice(0, cmdParts.index);
- if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
- deactivateCmdAndEmit();
- return;
- }
- }
-
- cmdCursorPos = cursor;
- if (cmdSlug === '') {
- activateCmd(cmdPrefix);
- return;
- }
- if (!isCmdActivated) activateCmd(cmdPrefix);
- requestAnimationFrame(() => {
- inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-caution)';
- });
- viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
- }
-
- function handleMsgTyping(e) {
- const msg = e.target.value;
- recognizeCmd(e.target.value);
- if (!isCmdActivated) processTyping(msg);
- }
-
- function handleKeyDown(e) {
- if (e.keyCode === 13 && e.shiftKey === false) {
- e.preventDefault();
-
- if (isCmdActivated) {
- viewEvent.emit('cmd_exe');
- } else sendMessage();
- }
- if (e.keyCode === 27 && isCmdActivated) {
- deactivateCmdAndEmit();
- e.preventDefault();
- }
- }
-
- function addEmoji(emoji) {
- textAreaRef.current.value += emoji.unicode;
- }
-
- function handleUploadClick() {
- if (attachment === null) uploadInputRef.current.click();
- else {
- roomsInput.cancelAttachment(roomId);
- }
- }
- function uploadFileChange(e) {
- const file = e.target.files.item(0);
- setAttachment(file);
- if (file !== null) roomsInput.setAttachment(roomId, file);
- }
-
- function renderInputs() {
- return (
- <>
- <div className={`channel-input__option-container${attachment === null ? '' : ' channel-attachment__option'}`}>
- <input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
- <IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
- </div>
- <div ref={inputBaseRef} className="channel-input__input-container">
- {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
- <ScrollView autoHide>
- <Text className="channel-input__textarea-wrapper">
- <TextareaAutosize
- ref={textAreaRef}
- onChange={handleMsgTyping}
- onResize={() => timelineScroll.autoReachBottom()}
- onKeyDown={handleKeyDown}
- placeholder="Send a message..."
- />
- </Text>
- </ScrollView>
- {isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
- <button ref={escBtnRef} tabIndex="-1" onClick={deactivateCmdAndEmit} className="btn-cmd-esc" type="button"><Text variant="b3">ESC</Text></button>
- </div>
- <div ref={rightOptionsRef} className="channel-input__option-container">
- <IconButton
- onClick={(e) => {
- const boxInfo = e.target.getBoundingClientRect();
- openEmojiBoard({
- x: boxInfo.x + (document.dir === 'rtl' ? -80 : 80),
- y: boxInfo.y - 250,
- detail: e.detail,
- }, addEmoji);
- }}
- tooltip="Emoji"
- src={EmojiIC}
- />
- <IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
- </div>
- </>
- );
- }
-
- function attachFile() {
- const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
- return (
- <div className="channel-attachment">
- <div className={`channel-attachment__preview${fileType !== 'image' ? ' channel-attachment__icon' : ''}`}>
- {fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
- {fileType === 'video' && <RawIcon src={VLCIC} />}
- {fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
- {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
- </div>
- <div className="channel-attachment__info">
- <Text variant="b1">{attachment.name}</Text>
- <Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
- </div>
- </div>
- );
- }
-
- function attachReply() {
- return (
- <div className="channel-reply">
- <IconButton
- onClick={() => {
- roomsInput.cancelReplyTo(roomId);
- setReplyTo(null);
- }}
- src={CrossIC}
- tooltip="Cancel reply"
- size="extra-small"
- />
- <MessageReply
- userId={replyTo.userId}
- name={getUsername(replyTo.userId)}
- color={colorMXID(replyTo.userId)}
- content={replyTo.content}
- />
- </div>
- );
- }
-
- return (
- <>
- { replyTo !== null && attachReply()}
- { attachment !== null && attachFile() }
- <form className="channel-input" onSubmit={(e) => { e.preventDefault(); }}>
- {
- roomTimeline.room.isSpaceRoom()
- ? <Text className="channel-input__space" variant="b1">Spaces are yet to be implemented</Text>
- : renderInputs()
- }
- </form>
- </>
- );
-}
-ChannelViewInput.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({
- reachBottom: PropTypes.func,
- autoReachBottom: PropTypes.func,
- tryRestoringScroll: PropTypes.func,
- enableSmoothScroll: PropTypes.func,
- disableSmoothScroll: PropTypes.func,
- }).isRequired,
- viewEvent: PropTypes.shape({}).isRequired,
-};
-
-export default ChannelViewInput;
+++ /dev/null
-.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
+++ /dev/null
-import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
-import './PeopleDrawer.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import { getUsernameOfRoomMember } from '../../../util/matrixUtil';
-import colorMXID from '../../../util/colorMXID';
-import { openInviteUser } from '../../../client/action/navigation';
-
-import Text from '../../atoms/text/Text';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import IconButton from '../../atoms/button/IconButton';
-import Button from '../../atoms/button/Button';
-import ScrollView from '../../atoms/scroll/ScrollView';
-import Input from '../../atoms/input/Input';
-import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
-
-import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
-
-function getPowerLabel(powerLevel) {
- switch (powerLevel) {
- case 100:
- return 'Admin';
- case 50:
- return 'Mod';
- default:
- return null;
- }
-}
-function compare(m1, m2) {
- let aName = m1.name;
- let bName = m2.name;
-
- // remove "#" from the room name
- // To ignore it in sorting
- aName = aName.replaceAll('#', '');
- bName = bName.replaceAll('#', '');
-
- if (aName.toLowerCase() < bName.toLowerCase()) {
- return -1;
- }
- if (aName.toLowerCase() > bName.toLowerCase()) {
- return 1;
- }
- return 0;
-}
-function sortByPowerLevel(m1, m2) {
- let pl1 = String(m1.powerLevel);
- let pl2 = String(m2.powerLevel);
-
- if (pl1 === '100') pl1 = '90.9';
- if (pl2 === '100') pl2 = '90.9';
-
- if (pl1.toLowerCase() > pl2.toLowerCase()) {
- return -1;
- }
- if (pl1.toLowerCase() < pl2.toLowerCase()) {
- return 1;
- }
- return 0;
-}
-
-function PeopleDrawer({ roomId }) {
- const PER_PAGE_MEMBER = 50;
- const room = initMatrix.matrixClient.getRoom(roomId);
- const totalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
- const [memberList, updateMemberList] = useState([]);
- let isRoomChanged = false;
-
- function loadMorePeople() {
- updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER));
- }
-
- useEffect(() => {
- updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER));
- room.loadMembersIfNeeded().then(() => {
- if (isRoomChanged) return;
- const newTotalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
- updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER));
- });
-
- return () => {
- isRoomChanged = true;
- };
- }, [roomId]);
-
- return (
- <div className="people-drawer">
- <Header>
- <TitleWrapper>
- <Text variant="s1">
- People
- <Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
- </Text>
- </TitleWrapper>
- <IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} />
- </Header>
- <div className="people-drawer__content-wrapper">
- <div className="people-drawer__scrollable">
- <ScrollView autoHide>
- <div className="people-drawer__content">
- {
- memberList.map((member) => (
- <PeopleSelector
- key={member.userId}
- onClick={() => alert('Viewing profile is yet to be implemented')}
- avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
- name={getUsernameOfRoomMember(member)}
- color={colorMXID(member.userId)}
- peopleRole={getPowerLabel(member.powerLevel)}
- />
- ))
- }
- <div className="people-drawer__load-more">
- {
- memberList.length !== totalMemberList.length && (
- <Button onClick={loadMorePeople}>View more</Button>
- )
- }
- </div>
- </div>
- </ScrollView>
- </div>
- <div className="people-drawer__sticky">
- <form onSubmit={(e) => e.preventDefault()} className="people-search">
- <Input type="text" placeholder="Search" required />
- </form>
- </div>
- </div>
- </div>
- );
-}
-
-PeopleDrawer.propTypes = {
- roomId: PropTypes.string.isRequired,
-};
-
-export default PeopleDrawer;
+++ /dev/null
-.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
+++ /dev/null
-import React from 'react';
-
-import initMatrix from '../../../client/initMatrix';
-import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
-
-function getTimelineJSXMessages() {
- return {
- join(user) {
- return (
- <>
- <b>{user}</b>
- {' joined the channel'}
- </>
- );
- },
- leave(user, reason) {
- const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
- return (
- <>
- <b>{user}</b>
- {' left the channel'}
- {reasonMsg}
- </>
- );
- },
- invite(inviter, user) {
- return (
- <>
- <b>{inviter}</b>
- {' invited '}
- <b>{user}</b>
- </>
- );
- },
- cancelInvite(inviter, user) {
- return (
- <>
- <b>{inviter}</b>
- {' canceled '}
- <b>{user}</b>
- {'\'s invite'}
- </>
- );
- },
- rejectInvite(user) {
- return (
- <>
- <b>{user}</b>
- {' rejected the invitation'}
- </>
- );
- },
- kick(actor, user, reason) {
- const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
- return (
- <>
- <b>{actor}</b>
- {' kicked '}
- <b>{user}</b>
- {reasonMsg}
- </>
- );
- },
- ban(actor, user, reason) {
- const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
- return (
- <>
- <b>{actor}</b>
- {' banned '}
- <b>{user}</b>
- {reasonMsg}
- </>
- );
- },
- unban(actor, user) {
- return (
- <>
- <b>{actor}</b>
- {' unbanned '}
- <b>{user}</b>
- </>
- );
- },
- avatarSets(user) {
- return (
- <>
- <b>{user}</b>
- {' set the avatar'}
- </>
- );
- },
- avatarChanged(user) {
- return (
- <>
- <b>{user}</b>
- {' changed the avatar'}
- </>
- );
- },
- avatarRemoved(user) {
- return (
- <>
- <b>{user}</b>
- {' removed the avatar'}
- </>
- );
- },
- nameSets(user, newName) {
- return (
- <>
- <b>{user}</b>
- {' set the display name to '}
- <b>{newName}</b>
- </>
- );
- },
- nameChanged(user, newName) {
- return (
- <>
- <b>{user}</b>
- {' changed the display name to '}
- <b>{newName}</b>
- </>
- );
- },
- nameRemoved(user, lastName) {
- return (
- <>
- <b>{user}</b>
- {' removed the display name '}
- <b>{lastName}</b>
- </>
- );
- },
- };
-}
-
-function getUsersActionJsx(roomId, userIds, actionStr) {
- const room = initMatrix.matrixClient.getRoom(roomId);
- const getUserDisplayName = (userId) => {
- if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
- return getUsername(userId);
- };
- const getUserJSX = (userId) => <b>{getUserDisplayName(userId)}</b>;
- if (!Array.isArray(userIds)) return 'Idle';
- if (userIds.length === 0) return 'Idle';
- const MAX_VISIBLE_COUNT = 3;
-
- const u1Jsx = getUserJSX(userIds[0]);
- // eslint-disable-next-line react/jsx-one-expression-per-line
- if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
-
- const u2Jsx = getUserJSX(userIds[1]);
- // eslint-disable-next-line react/jsx-one-expression-per-line
- if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
-
- const u3Jsx = getUserJSX(userIds[2]);
- if (userIds.length === 3) {
- // eslint-disable-next-line react/jsx-one-expression-per-line
- return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
- }
-
- const othersCount = userIds.length - MAX_VISIBLE_COUNT;
- // eslint-disable-next-line react/jsx-one-expression-per-line
- return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}</>;
-}
-
-function parseReply(rawContent) {
- if (rawContent.indexOf('>') !== 0) return null;
- let content = rawContent.slice(rawContent.indexOf('<') + 1);
- const user = content.slice(0, content.indexOf('>'));
-
- content = content.slice(content.indexOf('>') + 2);
- const replyContent = content.slice(0, content.indexOf('\n\n'));
- content = content.slice(content.indexOf('\n\n') + 2);
-
- if (user === '') return null;
-
- const isUserId = user.match(/^@.+:.+/);
-
- return {
- userId: isUserId ? user : null,
- displayName: isUserId ? null : user,
- replyContent,
- content,
- };
-}
-
-function parseTimelineChange(mEvent) {
- const tJSXMsgs = getTimelineJSXMessages();
- const makeReturnObj = (variant, content) => ({
- variant,
- content,
- });
- const content = mEvent.getContent();
- const prevContent = mEvent.getPrevContent();
- const sender = mEvent.getSender();
- const senderName = getUsername(sender);
- const userName = getUsername(mEvent.getStateKey());
-
- switch (content.membership) {
- case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
- case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
- case 'join':
- if (prevContent.membership === 'join') {
- if (content.displayname !== prevContent.displayname) {
- if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
- if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
- return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
- }
- if (content.avatar_url !== prevContent.avatar_url) {
- if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
- if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
- return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
- }
- return null;
- }
- return makeReturnObj('join', tJSXMsgs.join(senderName));
- case 'leave':
- if (sender === mEvent.getStateKey()) {
- switch (prevContent.membership) {
- case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
- default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
- }
- }
- switch (prevContent.membership) {
- case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
- case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
- // sender is not target and made the target leave,
- // if not from invite/ban then this is a kick
- default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
- }
- default: return null;
- }
-}
-
-function scrollToBottom(ref) {
- const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight;
- // eslint-disable-next-line no-param-reassign
- ref.current.scrollTop = maxScrollTop;
-}
-
-function isAtBottom(ref) {
- const { scrollHeight, scrollTop, offsetHeight } = ref.current;
- const scrollUptoBottom = scrollTop + offsetHeight;
-
- // scroll view have to div inside div which contains messages
- const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild;
- const lastChildHeight = lastMessage.offsetHeight;
-
- // auto scroll to bottom even if user has EXTRA_SPACE left to scroll
- const EXTRA_SPACE = 48;
-
- if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) {
- return true;
- }
- return false;
-}
-
-function autoScrollToBottom(ref) {
- if (isAtBottom(ref)) scrollToBottom(ref);
-}
-
-export {
- getTimelineJSXMessages,
- getUsersActionJsx,
- parseReply,
- parseTimelineChange,
- scrollToBottom,
- isAtBottom,
- autoScrollToBottom,
-};
+++ /dev/null
-import React, { useState, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './CreateChannel.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import { isRoomAliasAvailable } from '../../../util/matrixUtil';
-import * as roomActions from '../../../client/action/room';
-
-import Text from '../../atoms/text/Text';
-import Button from '../../atoms/button/Button';
-import Toggle from '../../atoms/button/Toggle';
-import IconButton from '../../atoms/button/IconButton';
-import Input from '../../atoms/input/Input';
-import Spinner from '../../atoms/spinner/Spinner';
-import PopupWindow from '../../molecules/popup-window/PopupWindow';
-import SettingTile from '../../molecules/setting-tile/SettingTile';
-
-import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
-import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-
-function CreateChannel({ isOpen, onRequestClose }) {
- const [isPublic, togglePublic] = useState(false);
- const [isEncrypted, toggleEncrypted] = useState(true);
- const [isValidAddress, updateIsValidAddress] = useState(null);
- const [isCreatingRoom, updateIsCreatingRoom] = useState(false);
- const [creatingError, updateCreatingError] = useState(null);
-
- const [titleValue, updateTitleValue] = useState(undefined);
- const [topicValue, updateTopicValue] = useState(undefined);
- const [addressValue, updateAddressValue] = useState(undefined);
-
- const addressRef = useRef(null);
- const topicRef = useRef(null);
- const nameRef = useRef(null);
-
- const userId = initMatrix.matrixClient.getUserId();
- const hsString = userId.slice(userId.indexOf(':'));
-
- function resetForm() {
- togglePublic(false);
- toggleEncrypted(true);
- updateIsValidAddress(null);
- updateIsCreatingRoom(false);
- updateCreatingError(null);
- updateTitleValue(undefined);
- updateTopicValue(undefined);
- updateAddressValue(undefined);
- }
-
- async function createRoom() {
- if (isCreatingRoom) return;
- updateIsCreatingRoom(true);
- updateCreatingError(null);
- const name = nameRef.current.value;
- let topic = topicRef.current.value;
- if (topic.trim() === '') topic = undefined;
- let roomAlias;
- if (isPublic) {
- roomAlias = addressRef?.current?.value;
- if (roomAlias.trim() === '') roomAlias = undefined;
- }
-
- try {
- await roomActions.create({
- name, topic, isPublic, roomAlias, isEncrypted,
- });
-
- resetForm();
- onRequestClose();
- } catch (e) {
- if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
- updateCreatingError('ERROR: Invalid characters in channel address');
- updateIsValidAddress(false);
- } else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
- updateCreatingError('ERROR: Channel address is already in use');
- updateIsValidAddress(false);
- } else updateCreatingError(e.message);
- }
- updateIsCreatingRoom(false);
- }
-
- function validateAddress(e) {
- const myAddress = e.target.value;
- updateIsValidAddress(null);
- updateAddressValue(e.target.value);
- updateCreatingError(null);
-
- setTimeout(async () => {
- if (myAddress !== addressRef.current.value) return;
- const roomAlias = addressRef.current.value;
- if (roomAlias === '') return;
- const roomAddress = `#${roomAlias}${hsString}`;
-
- if (await isRoomAliasAvailable(roomAddress)) {
- updateIsValidAddress(true);
- } else {
- updateIsValidAddress(false);
- }
- }, 1000);
- }
- function handleTitleChange(e) {
- if (e.target.value.trim() === '') updateTitleValue(undefined);
- updateTitleValue(e.target.value);
- }
- function handleTopicChange(e) {
- if (e.target.value.trim() === '') updateTopicValue(undefined);
- updateTopicValue(e.target.value);
- }
-
- return (
- <PopupWindow
- isOpen={isOpen}
- title="Create channel"
- contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
- onRequestClose={onRequestClose}
- >
- <div className="create-channel">
- <form className="create-channel__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
- <SettingTile
- title="Make channel public"
- options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
- content={<Text variant="b3">Public channel can be joined by anyone.</Text>}
- />
- {isPublic && (
- <div>
- <Text className="create-channel__address__label" variant="b2">Channel address</Text>
- <div className="create-channel__address">
- <Text variant="b1">#</Text>
- <Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
- <Text variant="b1">{hsString}</Text>
- </div>
- {isValidAddress === false && <Text className="create-channel__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
- </div>
- )}
- {!isPublic && (
- <SettingTile
- title="Enable end-to-end encryption"
- options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
- content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
- />
- )}
- <Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
- <div className="create-channel__name-wrapper">
- <Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Channel name" required />
- <Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
- </div>
- {isCreatingRoom && (
- <div className="create-channel__loading">
- <Spinner size="small" />
- <Text>Creating channel...</Text>
- </div>
- )}
- {typeof creatingError === 'string' && <Text className="create-channel__error" variant="b3">{creatingError}</Text>}
- </form>
- </div>
- </PopupWindow>
- );
-}
-
-CreateChannel.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onRequestClose: PropTypes.func.isRequired,
-};
-
-export default CreateChannel;
+++ /dev/null
-.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
--- /dev/null
+import React, { useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './CreateRoom.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import { isRoomAliasAvailable } from '../../../util/matrixUtil';
+import * as roomActions from '../../../client/action/room';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import Toggle from '../../atoms/button/Toggle';
+import IconButton from '../../atoms/button/IconButton';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+import PopupWindow from '../../molecules/popup-window/PopupWindow';
+import SettingTile from '../../molecules/setting-tile/SettingTile';
+
+import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+
+function CreateRoom({ isOpen, onRequestClose }) {
+ const [isPublic, togglePublic] = useState(false);
+ const [isEncrypted, toggleEncrypted] = useState(true);
+ const [isValidAddress, updateIsValidAddress] = useState(null);
+ const [isCreatingRoom, updateIsCreatingRoom] = useState(false);
+ const [creatingError, updateCreatingError] = useState(null);
+
+ const [titleValue, updateTitleValue] = useState(undefined);
+ const [topicValue, updateTopicValue] = useState(undefined);
+ const [addressValue, updateAddressValue] = useState(undefined);
+
+ const addressRef = useRef(null);
+ const topicRef = useRef(null);
+ const nameRef = useRef(null);
+
+ const userId = initMatrix.matrixClient.getUserId();
+ const hsString = userId.slice(userId.indexOf(':'));
+
+ function resetForm() {
+ togglePublic(false);
+ toggleEncrypted(true);
+ updateIsValidAddress(null);
+ updateIsCreatingRoom(false);
+ updateCreatingError(null);
+ updateTitleValue(undefined);
+ updateTopicValue(undefined);
+ updateAddressValue(undefined);
+ }
+
+ async function createRoom() {
+ if (isCreatingRoom) return;
+ updateIsCreatingRoom(true);
+ updateCreatingError(null);
+ const name = nameRef.current.value;
+ let topic = topicRef.current.value;
+ if (topic.trim() === '') topic = undefined;
+ let roomAlias;
+ if (isPublic) {
+ roomAlias = addressRef?.current?.value;
+ if (roomAlias.trim() === '') roomAlias = undefined;
+ }
+
+ try {
+ await roomActions.create({
+ name, topic, isPublic, roomAlias, isEncrypted,
+ });
+
+ resetForm();
+ onRequestClose();
+ } catch (e) {
+ if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
+ updateCreatingError('ERROR: Invalid characters in room address');
+ updateIsValidAddress(false);
+ } else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
+ updateCreatingError('ERROR: Room address is already in use');
+ updateIsValidAddress(false);
+ } else updateCreatingError(e.message);
+ }
+ updateIsCreatingRoom(false);
+ }
+
+ function validateAddress(e) {
+ const myAddress = e.target.value;
+ updateIsValidAddress(null);
+ updateAddressValue(e.target.value);
+ updateCreatingError(null);
+
+ setTimeout(async () => {
+ if (myAddress !== addressRef.current.value) return;
+ const roomAlias = addressRef.current.value;
+ if (roomAlias === '') return;
+ const roomAddress = `#${roomAlias}${hsString}`;
+
+ if (await isRoomAliasAvailable(roomAddress)) {
+ updateIsValidAddress(true);
+ } else {
+ updateIsValidAddress(false);
+ }
+ }, 1000);
+ }
+ function handleTitleChange(e) {
+ if (e.target.value.trim() === '') updateTitleValue(undefined);
+ updateTitleValue(e.target.value);
+ }
+ function handleTopicChange(e) {
+ if (e.target.value.trim() === '') updateTopicValue(undefined);
+ updateTopicValue(e.target.value);
+ }
+
+ return (
+ <PopupWindow
+ isOpen={isOpen}
+ title="Create room"
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={onRequestClose}
+ >
+ <div className="create-room">
+ <form className="create-room__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
+ <SettingTile
+ title="Make room public"
+ options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
+ content={<Text variant="b3">Public room can be joined by anyone.</Text>}
+ />
+ {isPublic && (
+ <div>
+ <Text className="create-room__address__label" variant="b2">Room address</Text>
+ <div className="create-room__address">
+ <Text variant="b1">#</Text>
+ <Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
+ <Text variant="b1">{hsString}</Text>
+ </div>
+ {isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
+ </div>
+ )}
+ {!isPublic && (
+ <SettingTile
+ title="Enable end-to-end encryption"
+ options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
+ content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
+ />
+ )}
+ <Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
+ <div className="create-room__name-wrapper">
+ <Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Room name" required />
+ <Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
+ </div>
+ {isCreatingRoom && (
+ <div className="create-room__loading">
+ <Spinner size="small" />
+ <Text>Creating room...</Text>
+ </div>
+ )}
+ {typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
+ </form>
+ </div>
+ </PopupWindow>
+ );
+}
+
+CreateRoom.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+export default CreateRoom;
--- /dev/null
+.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
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';
};
}, [procInvite]);
- function renderChannelTile(roomId) {
+ function renderRoomTile(roomId) {
const myRoom = initMatrix.matrixClient.getRoom(roomId);
const roomName = myRoom.name;
let roomAlias = myRoom.getCanonicalAlias();
if (roomAlias === null) roomAlias = myRoom.roomId;
return (
- <ChannelTile
+ <RoomTile
key={myRoom.roomId}
name={roomName}
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
const myRoom = initMatrix.matrixClient.getRoom(roomId);
const roomName = myRoom.name;
return (
- <ChannelTile
+ <RoomTile
key={myRoom.roomId}
name={roomName}
id={myRoom.getDMInviter()}
<Text variant="b3">Spaces</Text>
</div>
)}
- { Array.from(initMatrix.roomList.inviteSpaces).map(renderChannelTile) }
+ { Array.from(initMatrix.roomList.inviteSpaces).map(renderRoomTile) }
{ initMatrix.roomList.inviteRooms.size !== 0 && (
<div className="invites-content__subheading">
- <Text variant="b3">Channels</Text>
+ <Text variant="b3">Rooms</Text>
</div>
)}
- { Array.from(initMatrix.roomList.inviteRooms).map(renderChannelTile) }
+ { Array.from(initMatrix.roomList.inviteRooms).map(renderRoomTile) }
</div>
</PopupWindow>
);
}
}
- & .channel-tile {
+ & .room-tile {
margin-top: var(--sp-normal);
&__options {
align-self: flex-end;
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';
const userId = user.user_id;
const name = typeof user.display_name === 'string' ? user.display_name : userId;
return (
- <ChannelTile
+ <RoomTile
key={userId}
avatarSrc={typeof user.avatar_url === 'string' ? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop') : null}
name={name}
border-top: 1px solid var(--bg-surface-border);
}
- & .channel-tile {
+ & .room-tile {
margin-top: var(--sp-normal);
&__options {
align-self: flex-end;
<DrawerHeader activeTab={activeTab} />
<div className="drawer__content-wrapper">
<DrawerBradcrumb />
- <div className="channels__wrapper">
+ <div className="rooms__wrapper">
<ScrollView autoHide>
- <div className="channels-container">
+ <div className="rooms-container">
{
activeTab === 'home'
? <Home />
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;
}
- & > .channel-selector:first-child {
+ & > .room-selector:first-child {
margin-top: var(--sp-extra-tight);
}
import PropTypes from 'prop-types';
import {
- openPublicChannels, openCreateChannel, openInviteUser,
+ openPublicRooms, openCreateRoom, openInviteUser,
} from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
<ContextMenu
content={(hideMenu) => (
<>
- <MenuHeader>Add channel</MenuHeader>
+ <MenuHeader>Add room</MenuHeader>
<MenuItem
iconSrc={HashPlusIC}
- onClick={() => { hideMenu(); openCreateChannel(); }}
+ onClick={() => { hideMenu(); openCreateRoom(); }}
>
- Create new channel
+ Create new room
</MenuItem>
<MenuItem
iconSrc={HashSearchIC}
- onClick={() => { hideMenu(); openPublicChannels(); }}
+ onClick={() => { hideMenu(); openPublicRooms(); }}
>
- Add Public channel
+ Add public room
</MenuItem>
</>
)}
- render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add channel" src={PlusIC} size="normal" />)}
+ render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add room" src={PlusIC} size="normal" />)}
/>
)}
{/* <IconButton onClick={() => ''} tooltip="Menu" src={VerticalMenuIC} size="normal" /> */}
/>
))}
- { roomIds.length !== 0 && <Text className="cat-header" variant="b3">Channels</Text> }
+ { roomIds.length !== 0 && <Text className="cat-header" variant="b3">Rooms</Text> }
{ roomIds.map((id) => (
<Selector
key={id}
import { selectRoom } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation';
-import ChannelSelector from '../../molecules/channel-selector/ChannelSelector';
+import RoomSelector from '../../molecules/room-selector/RoomSelector';
import HashIC from '../../../../public/res/ic/outlined/hash.svg';
import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
}, []);
return (
- <ChannelSelector
+ <RoomSelector
key={roomId}
name={room.name}
roomId={roomId}
import colorMXID from '../../../util/colorMXID';
import logout from '../../../client/action/logout';
import {
- changeTab, openInviteList, openPublicChannels, openSettings,
+ changeTab, openInviteList, openPublicRooms, openSettings,
} from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation';
<div className="scrollable-content">
<div className="featured-container">
<SidebarAvatar active={activeTab === 'home'} onClick={() => changeTab('home')} tooltip="Home" iconSrc={HomeIC} />
- <SidebarAvatar active={activeTab === 'dms'} onClick={() => changeTab('dms')} tooltip="People" iconSrc={UserIC} />
- <SidebarAvatar onClick={() => openPublicChannels()} tooltip="Public channels" iconSrc={HashSearchIC} />
+ <SidebarAvatar active={activeTab === 'dm'} onClick={() => changeTab('dm')} tooltip="People" iconSrc={UserIC} />
+ <SidebarAvatar onClick={() => openPublicRooms()} tooltip="Public rooms" iconSrc={HashSearchIC} />
</div>
<div className="sidebar-divider" />
<div className="space-container" />
+++ /dev/null
-import React, { useState, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import './PublicChannels.scss';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import { selectRoom } from '../../../client/action/navigation';
-import * as roomActions from '../../../client/action/room';
-
-import Text from '../../atoms/text/Text';
-import Button from '../../atoms/button/Button';
-import IconButton from '../../atoms/button/IconButton';
-import Spinner from '../../atoms/spinner/Spinner';
-import Input from '../../atoms/input/Input';
-import PopupWindow from '../../molecules/popup-window/PopupWindow';
-import ChannelTile from '../../molecules/channel-tile/ChannelTile';
-
-import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
-
-const SEARCH_LIMIT = 20;
-
-function TryJoinWithAlias({ alias, onRequestClose }) {
- const [status, setStatus] = useState({
- isJoining: false,
- error: null,
- roomId: null,
- tempRoomId: null,
- });
- function handleOnRoomAdded(roomId) {
- if (status.tempRoomId !== null && status.tempRoomId !== roomId) return;
- setStatus({
- isJoining: false, error: null, roomId, tempRoomId: null,
- });
- }
-
- useEffect(() => {
- initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
- return () => {
- initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
- };
- }, [status]);
-
- async function joinWithAlias() {
- setStatus({
- isJoining: true, error: null, roomId: null, tempRoomId: null,
- });
- try {
- const roomId = await roomActions.join(alias, false);
- setStatus({
- isJoining: true, error: null, roomId: null, tempRoomId: roomId,
- });
- } catch (e) {
- setStatus({
- isJoining: false,
- error: `Unable to join ${alias}. Either channel is private or doesn't exist.`,
- roomId: null,
- tempRoomId: null,
- });
- }
- }
-
- return (
- <div className="try-join-with-alias">
- {status.roomId === null && !status.isJoining && status.error === null && (
- <Button onClick={() => joinWithAlias()}>{`Try joining ${alias}`}</Button>
- )}
- {status.isJoining && (
- <>
- <Spinner size="small" />
- <Text>{`Joining ${alias}...`}</Text>
- </>
- )}
- {status.roomId !== null && (
- <Button onClick={() => { onRequestClose(); selectRoom(status.roomId); }}>Open</Button>
- )}
- {status.error !== null && <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{status.error}</span></Text>}
- </div>
- );
-}
-
-TryJoinWithAlias.propTypes = {
- alias: PropTypes.string.isRequired,
- onRequestClose: PropTypes.func.isRequired,
-};
-
-function PublicChannels({ isOpen, searchTerm, onRequestClose }) {
- const [isSearching, updateIsSearching] = useState(false);
- const [isViewMore, updateIsViewMore] = useState(false);
- const [publicChannels, updatePublicChannels] = useState([]);
- const [nextBatch, updateNextBatch] = useState(undefined);
- const [searchQuery, updateSearchQuery] = useState({});
- const [joiningChannels, updateJoiningChannels] = useState(new Set());
-
- const channelNameRef = useRef(null);
- const hsRef = useRef(null);
- const userId = initMatrix.matrixClient.getUserId();
-
- async function searchChannels(viewMore) {
- let inputChannelName = channelNameRef?.current?.value || searchTerm;
- let isInputAlias = false;
- if (typeof inputChannelName === 'string') {
- isInputAlias = inputChannelName[0] === '#' && inputChannelName.indexOf(':') > 1;
- }
- const hsFromAlias = (isInputAlias) ? inputChannelName.slice(inputChannelName.indexOf(':') + 1) : null;
- let inputHs = hsFromAlias || hsRef?.current?.value;
-
- if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1);
- if (typeof inputChannelName !== 'string') inputChannelName = '';
-
- if (isSearching) return;
- if (viewMore !== true
- && inputChannelName === searchQuery.name
- && inputHs === searchQuery.homeserver
- ) return;
-
- updateSearchQuery({
- name: inputChannelName,
- homeserver: inputHs,
- });
- if (isViewMore !== viewMore) updateIsViewMore(viewMore);
- updateIsSearching(true);
-
- try {
- const result = await initMatrix.matrixClient.publicRooms({
- server: inputHs,
- limit: SEARCH_LIMIT,
- since: viewMore ? nextBatch : undefined,
- include_all_networks: true,
- filter: {
- generic_search_term: inputChannelName,
- },
- });
-
- const totalChannels = viewMore ? publicChannels.concat(result.chunk) : result.chunk;
- updatePublicChannels(totalChannels);
- updateNextBatch(result.next_batch);
- updateIsSearching(false);
- updateIsViewMore(false);
- if (totalChannels.length === 0) {
- updateSearchQuery({
- error: `No result found for "${inputChannelName}" on ${inputHs}`,
- alias: isInputAlias ? inputChannelName : null,
- });
- }
- } catch (e) {
- updatePublicChannels([]);
- updateSearchQuery({
- error: 'Something went wrong!',
- alias: isInputAlias ? inputChannelName : null,
- });
- updateIsSearching(false);
- updateNextBatch(undefined);
- updateIsViewMore(false);
- }
- }
-
- useEffect(() => {
- if (isOpen) searchChannels();
- }, [isOpen]);
-
- function handleOnRoomAdded(roomId) {
- if (joiningChannels.has(roomId)) {
- joiningChannels.delete(roomId);
- updateJoiningChannels(new Set(Array.from(joiningChannels)));
- }
- }
- useEffect(() => {
- initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
- return () => {
- initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
- };
- }, [joiningChannels]);
-
- function handleViewChannel(roomId) {
- selectRoom(roomId);
- onRequestClose();
- }
-
- function joinChannel(roomIdOrAlias) {
- joiningChannels.add(roomIdOrAlias);
- updateJoiningChannels(new Set(Array.from(joiningChannels)));
- roomActions.join(roomIdOrAlias, false);
- }
-
- function renderChannelList(channels) {
- return channels.map((channel) => {
- const alias = typeof channel.canonical_alias === 'string' ? channel.canonical_alias : channel.room_id;
- const name = typeof channel.name === 'string' ? channel.name : alias;
- const isJoined = initMatrix.roomList.rooms.has(channel.room_id);
- return (
- <ChannelTile
- key={channel.room_id}
- avatarSrc={typeof channel.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(channel.avatar_url, 42, 42, 'crop') : null}
- name={name}
- id={alias}
- memberCount={channel.num_joined_members}
- desc={typeof channel.topic === 'string' ? channel.topic : null}
- options={(
- <>
- {isJoined && <Button onClick={() => handleViewChannel(channel.room_id)}>Open</Button>}
- {!isJoined && (joiningChannels.has(channel.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinChannel(channel.aliases?.[0] || channel.room_id)} variant="primary">Join</Button>)}
- </>
- )}
- />
- );
- });
- }
-
- return (
- <PopupWindow
- isOpen={isOpen}
- title="Public channels"
- contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
- onRequestClose={onRequestClose}
- >
- <div className="public-channels">
- <form className="public-channels__form" onSubmit={(e) => { e.preventDefault(); searchChannels(); }}>
- <div className="public-channels__input-wrapper">
- <Input value={searchTerm} forwardRef={channelNameRef} label="Channel name or alias" />
- <Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
- </div>
- <Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
- </form>
- <div className="public-channels__search-status">
- {
- typeof searchQuery.name !== 'undefined' && isSearching && (
- searchQuery.name === ''
- ? (
- <div className="flex--center">
- <Spinner size="small" />
- <Text variant="b2">{`Loading public channels from ${searchQuery.homeserver}...`}</Text>
- </div>
- )
- : (
- <div className="flex--center">
- <Spinner size="small" />
- <Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
- </div>
- )
- )
- }
- {
- typeof searchQuery.name !== 'undefined' && !isSearching && (
- searchQuery.name === ''
- ? <Text variant="b2">{`Public channels on ${searchQuery.homeserver}.`}</Text>
- : <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
- )
- }
- { searchQuery.error && (
- <>
- <Text className="public-channels__search-error" variant="b2">{searchQuery.error}</Text>
- {typeof searchQuery.alias === 'string' && (
- <TryJoinWithAlias onRequestClose={onRequestClose} alias={searchQuery.alias} />
- )}
- </>
- )}
- </div>
- { publicChannels.length !== 0 && (
- <div className="public-channels__content">
- { renderChannelList(publicChannels) }
- </div>
- )}
- { publicChannels.length !== 0 && publicChannels.length % SEARCH_LIMIT === 0 && (
- <div className="public-channels__view-more">
- { isViewMore !== true && (
- <Button onClick={() => searchChannels(true)}>View more</Button>
- )}
- { isViewMore && <Spinner /> }
- </div>
- )}
- </div>
- </PopupWindow>
- );
-}
-
-PublicChannels.defaultProps = {
- searchTerm: undefined,
-};
-
-PublicChannels.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- searchTerm: PropTypes.string,
- onRequestClose: PropTypes.func.isRequired,
-};
-
-export default PublicChannels;
+++ /dev/null
-.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
--- /dev/null
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './PublicRooms.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { selectRoom } from '../../../client/action/navigation';
+import * as roomActions from '../../../client/action/room';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import IconButton from '../../atoms/button/IconButton';
+import Spinner from '../../atoms/spinner/Spinner';
+import Input from '../../atoms/input/Input';
+import PopupWindow from '../../molecules/popup-window/PopupWindow';
+import RoomTile from '../../molecules/room-tile/RoomTile';
+
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
+
+const SEARCH_LIMIT = 20;
+
+function TryJoinWithAlias({ alias, onRequestClose }) {
+ const [status, setStatus] = useState({
+ isJoining: false,
+ error: null,
+ roomId: null,
+ tempRoomId: null,
+ });
+ function handleOnRoomAdded(roomId) {
+ if (status.tempRoomId !== null && status.tempRoomId !== roomId) return;
+ setStatus({
+ isJoining: false, error: null, roomId, tempRoomId: null,
+ });
+ }
+
+ useEffect(() => {
+ initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+ return () => {
+ initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+ };
+ }, [status]);
+
+ async function joinWithAlias() {
+ setStatus({
+ isJoining: true, error: null, roomId: null, tempRoomId: null,
+ });
+ try {
+ const roomId = await roomActions.join(alias, false);
+ setStatus({
+ isJoining: true, error: null, roomId: null, tempRoomId: roomId,
+ });
+ } catch (e) {
+ setStatus({
+ isJoining: false,
+ error: `Unable to join ${alias}. Either room is private or doesn't exist.`,
+ roomId: null,
+ tempRoomId: null,
+ });
+ }
+ }
+
+ return (
+ <div className="try-join-with-alias">
+ {status.roomId === null && !status.isJoining && status.error === null && (
+ <Button onClick={() => joinWithAlias()}>{`Try joining ${alias}`}</Button>
+ )}
+ {status.isJoining && (
+ <>
+ <Spinner size="small" />
+ <Text>{`Joining ${alias}...`}</Text>
+ </>
+ )}
+ {status.roomId !== null && (
+ <Button onClick={() => { onRequestClose(); selectRoom(status.roomId); }}>Open</Button>
+ )}
+ {status.error !== null && <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{status.error}</span></Text>}
+ </div>
+ );
+}
+
+TryJoinWithAlias.propTypes = {
+ alias: PropTypes.string.isRequired,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
+ const [isSearching, updateIsSearching] = useState(false);
+ const [isViewMore, updateIsViewMore] = useState(false);
+ const [publicRooms, updatePublicRooms] = useState([]);
+ const [nextBatch, updateNextBatch] = useState(undefined);
+ const [searchQuery, updateSearchQuery] = useState({});
+ const [joiningRooms, updateJoiningRooms] = useState(new Set());
+
+ const roomNameRef = useRef(null);
+ const hsRef = useRef(null);
+ const userId = initMatrix.matrixClient.getUserId();
+
+ async function searchRooms(viewMore) {
+ let inputRoomName = roomNameRef?.current?.value || searchTerm;
+ let isInputAlias = false;
+ if (typeof inputRoomName === 'string') {
+ isInputAlias = inputRoomName[0] === '#' && inputRoomName.indexOf(':') > 1;
+ }
+ const hsFromAlias = (isInputAlias) ? inputRoomName.slice(inputRoomName.indexOf(':') + 1) : null;
+ let inputHs = hsFromAlias || hsRef?.current?.value;
+
+ if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1);
+ if (typeof inputRoomName !== 'string') inputRoomName = '';
+
+ if (isSearching) return;
+ if (viewMore !== true
+ && inputRoomName === searchQuery.name
+ && inputHs === searchQuery.homeserver
+ ) return;
+
+ updateSearchQuery({
+ name: inputRoomName,
+ homeserver: inputHs,
+ });
+ if (isViewMore !== viewMore) updateIsViewMore(viewMore);
+ updateIsSearching(true);
+
+ try {
+ const result = await initMatrix.matrixClient.publicRooms({
+ server: inputHs,
+ limit: SEARCH_LIMIT,
+ since: viewMore ? nextBatch : undefined,
+ include_all_networks: true,
+ filter: {
+ generic_search_term: inputRoomName,
+ },
+ });
+
+ const totalRooms = viewMore ? publicRooms.concat(result.chunk) : result.chunk;
+ updatePublicRooms(totalRooms);
+ updateNextBatch(result.next_batch);
+ updateIsSearching(false);
+ updateIsViewMore(false);
+ if (totalRooms.length === 0) {
+ updateSearchQuery({
+ error: `No result found for "${inputRoomName}" on ${inputHs}`,
+ alias: isInputAlias ? inputRoomName : null,
+ });
+ }
+ } catch (e) {
+ updatePublicRooms([]);
+ updateSearchQuery({
+ error: 'Something went wrong!',
+ alias: isInputAlias ? inputRoomName : null,
+ });
+ updateIsSearching(false);
+ updateNextBatch(undefined);
+ updateIsViewMore(false);
+ }
+ }
+
+ useEffect(() => {
+ if (isOpen) searchRooms();
+ }, [isOpen]);
+
+ function handleOnRoomAdded(roomId) {
+ if (joiningRooms.has(roomId)) {
+ joiningRooms.delete(roomId);
+ updateJoiningRooms(new Set(Array.from(joiningRooms)));
+ }
+ }
+ useEffect(() => {
+ initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+ return () => {
+ initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
+ };
+ }, [joiningRooms]);
+
+ function handleViewRoom(roomId) {
+ selectRoom(roomId);
+ onRequestClose();
+ }
+
+ function joinRoom(roomIdOrAlias) {
+ joiningRooms.add(roomIdOrAlias);
+ updateJoiningRooms(new Set(Array.from(joiningRooms)));
+ roomActions.join(roomIdOrAlias, false);
+ }
+
+ function renderRoomList(rooms) {
+ return rooms.map((room) => {
+ const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
+ const name = typeof room.name === 'string' ? room.name : alias;
+ const isJoined = initMatrix.roomList.rooms.has(room.room_id);
+ return (
+ <RoomTile
+ key={room.room_id}
+ avatarSrc={typeof room.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(room.avatar_url, 42, 42, 'crop') : null}
+ name={name}
+ id={alias}
+ memberCount={room.num_joined_members}
+ desc={typeof room.topic === 'string' ? room.topic : null}
+ options={(
+ <>
+ {isJoined && <Button onClick={() => handleViewRoom(room.room_id)}>Open</Button>}
+ {!isJoined && (joiningRooms.has(room.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinRoom(room.aliases?.[0] || room.room_id)} variant="primary">Join</Button>)}
+ </>
+ )}
+ />
+ );
+ });
+ }
+
+ return (
+ <PopupWindow
+ isOpen={isOpen}
+ title="Public rooms"
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={onRequestClose}
+ >
+ <div className="public-rooms">
+ <form className="public-rooms__form" onSubmit={(e) => { e.preventDefault(); searchRooms(); }}>
+ <div className="public-rooms__input-wrapper">
+ <Input value={searchTerm} forwardRef={roomNameRef} label="Room name or alias" />
+ <Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
+ </div>
+ <Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
+ </form>
+ <div className="public-rooms__search-status">
+ {
+ typeof searchQuery.name !== 'undefined' && isSearching && (
+ searchQuery.name === ''
+ ? (
+ <div className="flex--center">
+ <Spinner size="small" />
+ <Text variant="b2">{`Loading public rooms from ${searchQuery.homeserver}...`}</Text>
+ </div>
+ )
+ : (
+ <div className="flex--center">
+ <Spinner size="small" />
+ <Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
+ </div>
+ )
+ )
+ }
+ {
+ typeof searchQuery.name !== 'undefined' && !isSearching && (
+ searchQuery.name === ''
+ ? <Text variant="b2">{`Public rooms on ${searchQuery.homeserver}.`}</Text>
+ : <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
+ )
+ }
+ { searchQuery.error && (
+ <>
+ <Text className="public-rooms__search-error" variant="b2">{searchQuery.error}</Text>
+ {typeof searchQuery.alias === 'string' && (
+ <TryJoinWithAlias onRequestClose={onRequestClose} alias={searchQuery.alias} />
+ )}
+ </>
+ )}
+ </div>
+ { publicRooms.length !== 0 && (
+ <div className="public-rooms__content">
+ { renderRoomList(publicRooms) }
+ </div>
+ )}
+ { publicRooms.length !== 0 && publicRooms.length % SEARCH_LIMIT === 0 && (
+ <div className="public-rooms__view-more">
+ { isViewMore !== true && (
+ <Button onClick={() => searchRooms(true)}>View more</Button>
+ )}
+ { isViewMore && <Spinner /> }
+ </div>
+ )}
+ </div>
+ </PopupWindow>
+ );
+}
+
+PublicRooms.defaultProps = {
+ searchTerm: undefined,
+};
+
+PublicRooms.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ searchTerm: PropTypes.string,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+export default PublicRooms;
--- /dev/null
+.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
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,
});
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({
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);
};
isOpen={isInviteList}
onRequestClose={() => changeInviteList(false)}
/>
- <PublicChannels
- isOpen={publicChannels.isOpen}
- searchTerm={publicChannels.searchTerm}
- onRequestClose={() => changePublicChannels({ isOpen: false, searchTerm: undefined })}
+ <PublicRooms
+ isOpen={publicRooms.isOpen}
+ searchTerm={publicRooms.searchTerm}
+ onRequestClose={() => changePublicRooms({ isOpen: false, searchTerm: undefined })}
/>
- <CreateChannel
- isOpen={isCreateChannel}
- onRequestClose={() => changeCreateChannel(false)}
+ <CreateRoom
+ isOpen={isCreateRoom}
+ onRequestClose={() => changeCreateRoom(false)}
/>
<InviteUser
isOpen={inviteUser.isOpen}
--- /dev/null
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './PeopleDrawer.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import { getUsernameOfRoomMember } from '../../../util/matrixUtil';
+import colorMXID from '../../../util/colorMXID';
+import { openInviteUser } from '../../../client/action/navigation';
+
+import Text from '../../atoms/text/Text';
+import Header, { TitleWrapper } from '../../atoms/header/Header';
+import IconButton from '../../atoms/button/IconButton';
+import Button from '../../atoms/button/Button';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import Input from '../../atoms/input/Input';
+import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
+
+import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
+
+function getPowerLabel(powerLevel) {
+ switch (powerLevel) {
+ case 100:
+ return 'Admin';
+ case 50:
+ return 'Mod';
+ default:
+ return null;
+ }
+}
+function compare(m1, m2) {
+ let aName = m1.name;
+ let bName = m2.name;
+
+ // remove "#" from the room name
+ // To ignore it in sorting
+ aName = aName.replaceAll('#', '');
+ bName = bName.replaceAll('#', '');
+
+ if (aName.toLowerCase() < bName.toLowerCase()) {
+ return -1;
+ }
+ if (aName.toLowerCase() > bName.toLowerCase()) {
+ return 1;
+ }
+ return 0;
+}
+function sortByPowerLevel(m1, m2) {
+ let pl1 = String(m1.powerLevel);
+ let pl2 = String(m2.powerLevel);
+
+ if (pl1 === '100') pl1 = '90.9';
+ if (pl2 === '100') pl2 = '90.9';
+
+ if (pl1.toLowerCase() > pl2.toLowerCase()) {
+ return -1;
+ }
+ if (pl1.toLowerCase() < pl2.toLowerCase()) {
+ return 1;
+ }
+ return 0;
+}
+
+function PeopleDrawer({ roomId }) {
+ const PER_PAGE_MEMBER = 50;
+ const room = initMatrix.matrixClient.getRoom(roomId);
+ const totalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
+ const [memberList, updateMemberList] = useState([]);
+ let isRoomChanged = false;
+
+ function loadMorePeople() {
+ updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER));
+ }
+
+ useEffect(() => {
+ updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER));
+ room.loadMembersIfNeeded().then(() => {
+ if (isRoomChanged) return;
+ const newTotalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
+ updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER));
+ });
+
+ return () => {
+ isRoomChanged = true;
+ };
+ }, [roomId]);
+
+ return (
+ <div className="people-drawer">
+ <Header>
+ <TitleWrapper>
+ <Text variant="s1">
+ People
+ <Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
+ </Text>
+ </TitleWrapper>
+ <IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} />
+ </Header>
+ <div className="people-drawer__content-wrapper">
+ <div className="people-drawer__scrollable">
+ <ScrollView autoHide>
+ <div className="people-drawer__content">
+ {
+ memberList.map((member) => (
+ <PeopleSelector
+ key={member.userId}
+ onClick={() => alert('Viewing profile is yet to be implemented')}
+ avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
+ name={getUsernameOfRoomMember(member)}
+ color={colorMXID(member.userId)}
+ peopleRole={getPowerLabel(member.powerLevel)}
+ />
+ ))
+ }
+ <div className="people-drawer__load-more">
+ {
+ memberList.length !== totalMemberList.length && (
+ <Button onClick={loadMorePeople}>View more</Button>
+ )
+ }
+ </div>
+ </div>
+ </ScrollView>
+ </div>
+ <div className="people-drawer__sticky">
+ <form onSubmit={(e) => e.preventDefault()} className="people-search">
+ <Input type="text" placeholder="Search" required />
+ </form>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+PeopleDrawer.propTypes = {
+ roomId: PropTypes.string.isRequired,
+};
+
+export default PeopleDrawer;
--- /dev/null
+.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
--- /dev/null
+import React, { useState, useEffect } from 'react';
+import './Room.scss';
+
+import cons from '../../../client/state/cons';
+import navigation from '../../../client/state/navigation';
+
+import Welcome from '../welcome/Welcome';
+import RoomView from './RoomView';
+import PeopleDrawer from './PeopleDrawer';
+
+function Room() {
+ const [selectedRoomId, changeSelectedRoomId] = useState(null);
+ const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible);
+ useEffect(() => {
+ const handleRoomSelected = (roomId) => {
+ changeSelectedRoomId(roomId);
+ };
+ const handleDrawerToggling = (visiblity) => {
+ toggleDrawerVisiblity(visiblity);
+ };
+ navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+ navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
+
+ return () => {
+ navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+ navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
+ };
+ }, []);
+
+ if (selectedRoomId === null) return <Welcome />;
+
+ return (
+ <div className="room-container">
+ <RoomView roomId={selectedRoomId} />
+ { isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
+ </div>
+ );
+}
+
+export default Room;
--- /dev/null
+.room-container {
+ display: flex;
+ height: 100%;
+}
\ No newline at end of file
--- /dev/null
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './RoomView.scss';
+
+import EventEmitter from 'events';
+
+import RoomTimeline from '../../../client/state/RoomTimeline';
+
+import ScrollView from '../../atoms/scroll/ScrollView';
+
+import RoomViewHeader from './RoomViewHeader';
+import RoomViewContent from './RoomViewContent';
+import RoomViewFloating from './RoomViewFloating';
+import RoomViewInput from './RoomViewInput';
+import RoomViewCmdBar from './RoomViewCmdBar';
+
+import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
+
+const viewEvent = new EventEmitter();
+
+let lastScrollTop = 0;
+let lastScrollHeight = 0;
+let isReachedBottom = true;
+let isReachedTop = false;
+function RoomView({ roomId }) {
+ const [roomTimeline, updateRoomTimeline] = useState(null);
+ const timelineSVRef = useRef(null);
+
+ useEffect(() => {
+ roomTimeline?.removeInternalListeners();
+ updateRoomTimeline(new RoomTimeline(roomId));
+ isReachedBottom = true;
+ isReachedTop = false;
+ }, [roomId]);
+
+ const timelineScroll = {
+ reachBottom() {
+ scrollToBottom(timelineSVRef);
+ },
+ autoReachBottom() {
+ autoScrollToBottom(timelineSVRef);
+ },
+ tryRestoringScroll() {
+ const sv = timelineSVRef.current;
+ const { scrollHeight } = sv;
+
+ if (lastScrollHeight === scrollHeight) return;
+
+ if (lastScrollHeight < scrollHeight) {
+ sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
+ } else {
+ timelineScroll.reachBottom();
+ }
+ },
+ enableSmoothScroll() {
+ timelineSVRef.current.style.scrollBehavior = 'smooth';
+ },
+ disableSmoothScroll() {
+ timelineSVRef.current.style.scrollBehavior = 'auto';
+ },
+ isScrollable() {
+ const oHeight = timelineSVRef.current.offsetHeight;
+ const sHeight = timelineSVRef.current.scrollHeight;
+ if (sHeight > oHeight) return true;
+ return false;
+ },
+ };
+
+ function onTimelineScroll(e) {
+ const { scrollTop, scrollHeight, offsetHeight } = e.target;
+ const scrollBottom = scrollTop + offsetHeight;
+ lastScrollTop = scrollTop;
+ lastScrollHeight = scrollHeight;
+
+ const PLACEHOLDER_HEIGHT = 96;
+ const PLACEHOLDER_COUNT = 3;
+
+ const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT;
+ const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2);
+
+ if (!isReachedBottom && isAtBottom(timelineSVRef)) {
+ isReachedBottom = true;
+ viewEvent.emit('toggle-reached-bottom', true);
+ }
+ if (isReachedBottom && !isAtBottom(timelineSVRef)) {
+ isReachedBottom = false;
+ viewEvent.emit('toggle-reached-bottom', false);
+ }
+ // TOP of timeline
+ if (scrollTop < topPagKeyPoint && isReachedTop === false) {
+ isReachedTop = true;
+ viewEvent.emit('reached-top');
+ return;
+ }
+ isReachedTop = false;
+
+ // BOTTOM of timeline
+ if (scrollBottom > bottomPagKeyPoint) {
+ // TODO:
+ }
+ }
+
+ return (
+ <div className="room-view">
+ <RoomViewHeader roomId={roomId} />
+ <div className="room-view__content-wrapper">
+ <div className="room-view__scrollable">
+ <ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide>
+ {roomTimeline !== null && (
+ <RoomViewContent
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ timelineScroll={timelineScroll}
+ viewEvent={viewEvent}
+ />
+ )}
+ </ScrollView>
+ {roomTimeline !== null && (
+ <RoomViewFloating
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ timelineScroll={timelineScroll}
+ viewEvent={viewEvent}
+ />
+ )}
+ </div>
+ {roomTimeline !== null && (
+ <div className="room-view__sticky">
+ <RoomViewInput
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ timelineScroll={timelineScroll}
+ viewEvent={viewEvent}
+ />
+ <RoomViewCmdBar
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ viewEvent={viewEvent}
+ />
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+RoomView.propTypes = {
+ roomId: PropTypes.string.isRequired,
+};
+
+export default RoomView;
--- /dev/null
+.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
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewCmdBar.scss';
+import parse from 'html-react-parser';
+import twemoji from 'twemoji';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { toggleMarkdown } from '../../../client/action/settings';
+import * as roomActions from '../../../client/action/room';
+import {
+ selectRoom,
+ openCreateRoom,
+ openPublicRooms,
+ openInviteUser,
+ openReadReceipts,
+} from '../../../client/action/navigation';
+import { emojis } from '../emoji-board/emoji';
+import AsyncSearch from '../../../util/AsyncSearch';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import IconButton from '../../atoms/button/IconButton';
+import ContextMenu, { MenuHeader } from '../../atoms/context-menu/ContextMenu';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import SettingTile from '../../molecules/setting-tile/SettingTile';
+import TimelineChange from '../../molecules/message/TimelineChange';
+
+import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
+
+import { getUsersActionJsx } from './common';
+
+const commands = [{
+ name: 'markdown',
+ description: 'Toggle markdown for messages.',
+ exe: () => toggleMarkdown(),
+}, {
+ name: 'startDM',
+ isOptions: true,
+ description: 'Start direct message with user. Example: /startDM/@johndoe.matrix.org',
+ exe: (roomId, searchTerm) => openInviteUser(undefined, searchTerm),
+}, {
+ name: 'createRoom',
+ description: 'Create new room',
+ exe: () => openCreateRoom(),
+}, {
+ name: 'join',
+ isOptions: true,
+ description: 'Join room with alias. Example: /join/#cinny:matrix.org',
+ exe: (roomId, searchTerm) => openPublicRooms(searchTerm),
+}, {
+ name: 'leave',
+ description: 'Leave current room',
+ exe: (roomId) => roomActions.leave(roomId),
+}, {
+ name: 'invite',
+ isOptions: true,
+ description: 'Invite user to room. Example: /invite/@johndoe:matrix.org',
+ exe: (roomId, searchTerm) => openInviteUser(roomId, searchTerm),
+}];
+
+function CmdHelp() {
+ return (
+ <ContextMenu
+ placement="top"
+ content={(
+ <>
+ <MenuHeader>General command</MenuHeader>
+ <Text variant="b2">/command_name</Text>
+ <MenuHeader>Go-to commands</MenuHeader>
+ <Text variant="b2">{'>*space_name'}</Text>
+ <Text variant="b2">{'>#room_name'}</Text>
+ <Text variant="b2">{'>@people_name'}</Text>
+ <MenuHeader>Autofill commands</MenuHeader>
+ <Text variant="b2">:emoji_name</Text>
+ <Text variant="b2">@name</Text>
+ </>
+ )}
+ render={(toggleMenu) => (
+ <IconButton
+ src={CmdIC}
+ size="extra-small"
+ onClick={toggleMenu}
+ tooltip="Commands"
+ />
+ )}
+ />
+ );
+}
+
+function ViewCmd() {
+ function renderAllCmds() {
+ return commands.map((command) => (
+ <SettingTile
+ key={command.name}
+ title={command.name}
+ content={(<Text variant="b3">{command.description}</Text>)}
+ />
+ ));
+ }
+ return (
+ <ContextMenu
+ maxWidth={250}
+ placement="top"
+ content={(
+ <>
+ <MenuHeader>General commands</MenuHeader>
+ {renderAllCmds()}
+ </>
+ )}
+ render={(toggleMenu) => (
+ <span>
+ <Button onClick={toggleMenu}><span className="text text-b3">View all</span></Button>
+ </span>
+ )}
+ />
+ );
+}
+
+function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
+ const [followingMembers, setFollowingMembers] = useState([]);
+ const mx = initMatrix.matrixClient;
+
+ function handleOnMessageSent() {
+ setFollowingMembers([]);
+ }
+
+ function updateFollowingMembers() {
+ const room = mx.getRoom(roomId);
+ const { timeline } = room;
+ const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
+ const myUserId = mx.getUserId();
+ setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
+ }
+
+ useEffect(() => updateFollowingMembers(), [roomId]);
+
+ useEffect(() => {
+ roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+ viewEvent.on('message_sent', handleOnMessageSent);
+ return () => {
+ roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+ viewEvent.removeListener('message_sent', handleOnMessageSent);
+ };
+ }, [roomTimeline]);
+
+ const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1];
+ return followingMembers.length !== 0 && (
+ <TimelineChange
+ variant="follow"
+ content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')}
+ time=""
+ onClick={() => openReadReceipts(roomId, lastMEvent.getId())}
+ />
+ );
+}
+
+FollowingMembers.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+function getCmdActivationMessage(prefix) {
+ function genMessage(prime, secondary) {
+ return (
+ <>
+ <span>{prime}</span>
+ <span>{secondary}</span>
+ </>
+ );
+ }
+ const cmd = {
+ '/': () => genMessage('General command mode activated. ', 'Type command name for suggestions.'),
+ '>*': () => genMessage('Go-to command mode activated. ', 'Type space name for suggestions.'),
+ '>#': () => genMessage('Go-to command mode activated. ', 'Type room name for suggestions.'),
+ '>@': () => genMessage('Go-to command mode activated. ', 'Type people name for suggestions.'),
+ ':': () => genMessage('Emoji autofill command mode activated. ', 'Type emoji shortcut for suggestions.'),
+ '@': () => genMessage('Name autofill command mode activated. ', 'Type name for suggestions.'),
+ };
+ return cmd[prefix]?.();
+}
+
+function CmdItem({ onClick, children }) {
+ return (
+ <button className="cmd-item" onClick={onClick} type="button">
+ {children}
+ </button>
+ );
+}
+CmdItem.propTypes = {
+ onClick: PropTypes.func.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
+ function getGenCmdSuggestions(cmdPrefix, cmds) {
+ const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?';
+ return cmds.map((cmd) => (
+ <CmdItem
+ key={cmd.name}
+ onClick={() => {
+ fireCmd({
+ prefix: cmdPrefix,
+ option,
+ result: cmd,
+ });
+ }}
+ >
+ <Text variant="b2">{`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`}</Text>
+ </CmdItem>
+ ));
+ }
+
+ function getRoomsSuggestion(cmdPrefix, rooms) {
+ return rooms.map((room) => (
+ <CmdItem
+ key={room.roomId}
+ onClick={() => {
+ fireCmd({
+ prefix: cmdPrefix,
+ result: room,
+ });
+ }}
+ >
+ <Text variant="b2">{room.name}</Text>
+ </CmdItem>
+ ));
+ }
+
+ function getEmojiSuggestion(emPrefix, emos) {
+ return emos.map((emoji) => (
+ <CmdItem
+ key={emoji.hexcode}
+ onClick={() => fireCmd({
+ prefix: emPrefix,
+ result: emoji,
+ })}
+ >
+ {
+ parse(twemoji.parse(
+ emoji.unicode,
+ {
+ attributes: () => ({
+ unicode: emoji.unicode,
+ shortcodes: emoji.shortcodes?.toString(),
+ }),
+ },
+ ))
+ }
+ <Text variant="b2">{`:${emoji.shortcode}:`}</Text>
+ </CmdItem>
+ ));
+ }
+
+ function getNameSuggestion(namePrefix, members) {
+ return members.map((member) => (
+ <CmdItem
+ key={member.userId}
+ onClick={() => {
+ fireCmd({
+ prefix: namePrefix,
+ result: member,
+ });
+ }}
+ >
+ <Text variant="b2">{member.name}</Text>
+ </CmdItem>
+ ));
+ }
+
+ const cmd = {
+ '/': (cmds) => getGenCmdSuggestions(prefix, cmds),
+ '>*': (spaces) => getRoomsSuggestion(prefix, spaces),
+ '>#': (rooms) => getRoomsSuggestion(prefix, rooms),
+ '>@': (peoples) => getRoomsSuggestion(prefix, peoples),
+ ':': (emos) => getEmojiSuggestion(prefix, emos),
+ '@': (members) => getNameSuggestion(prefix, members),
+ };
+ return cmd[prefix]?.(suggestions);
+}
+
+const asyncSearch = new AsyncSearch();
+let cmdPrefix;
+let cmdOption;
+function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
+ const [cmd, setCmd] = useState(null);
+
+ function displaySuggestions(suggestions) {
+ if (suggestions.length === 0) {
+ setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
+ viewEvent.emit('cmd_error');
+ return;
+ }
+ setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
+ }
+
+ function processCmd(prefix, slug) {
+ let searchTerm = slug;
+ cmdOption = undefined;
+ cmdPrefix = prefix;
+ if (prefix === '/') {
+ const cmdSlugParts = slug.split('/');
+ [searchTerm, cmdOption] = cmdSlugParts;
+ }
+ if (prefix === ':') {
+ if (searchTerm.length <= 3) {
+ if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
+ else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
+ else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
+ else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
+ else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
+ else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
+ else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
+ else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
+ else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
+ else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
+ else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
+ else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
+ }
+ }
+
+ asyncSearch.search(searchTerm);
+ }
+ function activateCmd(prefix) {
+ setCmd({ prefix });
+ cmdPrefix = prefix;
+
+ const { roomList, matrixClient } = initMatrix;
+ function getRooms(roomIds) {
+ return roomIds.map((rId) => {
+ const room = matrixClient.getRoom(rId);
+ return {
+ name: room.name,
+ roomId: room.roomId,
+ };
+ });
+ }
+ const setupSearch = {
+ '/': () => asyncSearch.setup(commands, { keys: ['name'], isContain: true }),
+ '>*': () => asyncSearch.setup(getRooms([...roomList.spaces]), { keys: ['name'], limit: 20 }),
+ '>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }),
+ '>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { keys: ['name'], limit: 20 }),
+ ':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 20 }),
+ '@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({
+ name: member.name,
+ userId: member.userId.slice(1),
+ })), { keys: ['name', 'userId'], limit: 20 }),
+ };
+ setupSearch[prefix]?.();
+ }
+ function deactivateCmd() {
+ setCmd(null);
+ cmdOption = undefined;
+ cmdPrefix = undefined;
+ }
+ function fireCmd(myCmd) {
+ if (myCmd.prefix.match(/^>[*#@]$/)) {
+ selectRoom(myCmd.result.roomId);
+ viewEvent.emit('cmd_fired');
+ }
+ if (myCmd.prefix === '/') {
+ myCmd.result.exe(roomId, myCmd.option);
+ viewEvent.emit('cmd_fired');
+ }
+ if (myCmd.prefix === ':') {
+ viewEvent.emit('cmd_fired', {
+ replace: myCmd.result.unicode,
+ });
+ }
+ if (myCmd.prefix === '@') {
+ viewEvent.emit('cmd_fired', {
+ replace: myCmd.result.name,
+ });
+ }
+ deactivateCmd();
+ }
+ function executeCmd() {
+ if (cmd.suggestions.length === 0) return;
+ fireCmd({
+ prefix: cmd.prefix,
+ option: cmd.option,
+ result: cmd.suggestions[0],
+ });
+ }
+
+ function listenKeyboard(event) {
+ const { activeElement } = document;
+ const lastCmdItem = document.activeElement.parentNode.lastElementChild;
+ if (event.keyCode === 27) {
+ if (activeElement.className !== 'cmd-item') return;
+ viewEvent.emit('focus_msg_input');
+ }
+ if (event.keyCode === 9) {
+ if (lastCmdItem.className !== 'cmd-item') return;
+ if (lastCmdItem !== activeElement) return;
+ if (event.shiftKey) return;
+ viewEvent.emit('focus_msg_input');
+ event.preventDefault();
+ }
+ }
+
+ useEffect(() => {
+ viewEvent.on('cmd_activate', activateCmd);
+ viewEvent.on('cmd_deactivate', deactivateCmd);
+ return () => {
+ deactivateCmd();
+ viewEvent.removeListener('cmd_activate', activateCmd);
+ viewEvent.removeListener('cmd_deactivate', deactivateCmd);
+ };
+ }, [roomId]);
+
+ useEffect(() => {
+ if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
+ viewEvent.on('cmd_process', processCmd);
+ viewEvent.on('cmd_exe', executeCmd);
+ asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
+ return () => {
+ if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
+
+ viewEvent.removeListener('cmd_process', processCmd);
+ viewEvent.removeListener('cmd_exe', executeCmd);
+ asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
+ };
+ }, [cmd]);
+
+ if (typeof cmd?.error === 'string') {
+ return (
+ <div className="cmd-bar">
+ <div className="cmd-bar__info">
+ <div className="cmd-bar__info-indicator--error" />
+ </div>
+ <div className="cmd-bar__content">
+ <Text className="cmd-bar__content-error" variant="b2">{cmd.error}</Text>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="cmd-bar">
+ <div className="cmd-bar__info">
+ {cmd === null && <CmdHelp />}
+ {cmd !== null && typeof cmd.suggestions === 'undefined' && <div className="cmd-bar__info-indicator" /> }
+ {cmd !== null && typeof cmd.suggestions !== 'undefined' && <Text variant="b3">TAB</Text>}
+ </div>
+ <div className="cmd-bar__content">
+ {cmd === null && (
+ <FollowingMembers
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ viewEvent={viewEvent}
+ />
+ )}
+ {cmd !== null && typeof cmd.suggestions === 'undefined' && <Text className="cmd-bar__content-help" variant="b2">{getCmdActivationMessage(cmd.prefix)}</Text>}
+ {cmd !== null && typeof cmd.suggestions !== 'undefined' && (
+ <ScrollView horizontal vertical={false} invisible>
+ <div className="cmd-bar__content__suggestions">{getCmdSuggestions(cmd, fireCmd)}</div>
+ </ScrollView>
+ )}
+ </div>
+ <div className="cmd-bar__more">
+ {cmd !== null && cmd.prefix === '/' && <ViewCmd />}
+ </div>
+ </div>
+ );
+}
+RoomViewCmdBar.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewCmdBar;
--- /dev/null
+.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
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect, useLayoutEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewContent.scss';
+
+import dateFormat from 'dateformat';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
+import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil';
+import colorMXID from '../../../util/colorMXID';
+import { diffMinutes, isNotInSameDay } from '../../../util/common';
+import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation';
+
+import Divider from '../../atoms/divider/Divider';
+import Avatar from '../../atoms/avatar/Avatar';
+import IconButton from '../../atoms/button/IconButton';
+import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
+import {
+ Message,
+ MessageHeader,
+ MessageReply,
+ MessageContent,
+ MessageEdit,
+ MessageReactionGroup,
+ MessageReaction,
+ MessageOptions,
+ PlaceholderMessage,
+} from '../../molecules/message/Message';
+import * as Media from '../../molecules/media/Media';
+import RoomIntro from '../../molecules/room-intro/RoomIntro';
+import TimelineChange from '../../molecules/message/TimelineChange';
+
+import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
+import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
+import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
+import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
+import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
+import BinIC from '../../../../public/res/ic/outlined/bin.svg';
+
+import { parseReply, parseTimelineChange } from './common';
+
+const MAX_MSG_DIFF_MINUTES = 5;
+
+function genPlaceholders() {
+ return (
+ <>
+ <PlaceholderMessage key="placeholder-1" />
+ <PlaceholderMessage key="placeholder-2" />
+ <PlaceholderMessage key="placeholder-3" />
+ </>
+ );
+}
+
+function isMedia(mE) {
+ return (
+ mE.getContent()?.msgtype === 'm.file'
+ || mE.getContent()?.msgtype === 'm.image'
+ || mE.getContent()?.msgtype === 'm.audio'
+ || mE.getContent()?.msgtype === 'm.video'
+ || mE.getType() === 'm.sticker'
+ );
+}
+
+function genMediaContent(mE) {
+ const mx = initMatrix.matrixClient;
+ const mContent = mE.getContent();
+ if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
+
+ let mediaMXC = mContent?.url;
+ const isEncryptedFile = typeof mediaMXC === 'undefined';
+ if (isEncryptedFile) mediaMXC = mContent?.file?.url;
+
+ let thumbnailMXC = mContent?.info?.thumbnail_url;
+
+ if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
+
+ let msgType = mE.getContent()?.msgtype;
+ if (mE.getType() === 'm.sticker') msgType = 'm.image';
+
+ switch (msgType) {
+ case 'm.file':
+ return (
+ <Media.File
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ type={mContent.info?.mimetype}
+ file={mContent.file || null}
+ />
+ );
+ case 'm.image':
+ return (
+ <Media.Image
+ name={mContent.body}
+ width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
+ height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ file={isEncryptedFile ? mContent.file : null}
+ type={mContent.info?.mimetype}
+ />
+ );
+ case 'm.audio':
+ return (
+ <Media.Audio
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ type={mContent.info?.mimetype}
+ file={mContent.file || null}
+ />
+ );
+ case 'm.video':
+ if (typeof thumbnailMXC === 'undefined') {
+ thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
+ }
+ return (
+ <Media.Video
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
+ thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
+ thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
+ width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
+ height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
+ file={isEncryptedFile ? mContent.file : null}
+ type={mContent.info?.mimetype}
+ />
+ );
+ default:
+ return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
+ }
+}
+
+function genRoomIntro(mEvent, roomTimeline) {
+ const mx = initMatrix.matrixClient;
+ const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+ const isDM = initMatrix.roomList.directs.has(roomTimeline.roomId);
+ let avatarSrc = roomTimeline.room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
+ avatarSrc = isDM ? roomTimeline.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
+ return (
+ <RoomIntro
+ key={mEvent ? mEvent.getId() : 'room-intro'}
+ roomId={roomTimeline.roomId}
+ avatarSrc={avatarSrc}
+ name={roomTimeline.room.name}
+ heading={`Welcome to ${roomTimeline.room.name}`}
+ desc={`This is the beginning of ${roomTimeline.room.name} room.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
+ time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
+ />
+ );
+}
+
+function getMyEmojiEventId(emojiKey, eventId, roomTimeline) {
+ const mx = initMatrix.matrixClient;
+ const rEvents = roomTimeline.reactionTimeline.get(eventId);
+ let rEventId = null;
+ rEvents?.find((rE) => {
+ if (rE.getRelation() === null) return false;
+ if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
+ rEventId = rE.getId();
+ return true;
+ }
+ return false;
+ });
+ return rEventId;
+}
+
+function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
+ const myAlreadyReactEventId = getMyEmojiEventId(emojiKey, eventId, roomTimeline);
+ if (typeof myAlreadyReactEventId === 'string') {
+ if (myAlreadyReactEventId.indexOf('~') === 0) return;
+ redactEvent(roomId, myAlreadyReactEventId);
+ return;
+ }
+ sendReaction(roomId, eventId, emojiKey);
+}
+
+function pickEmoji(e, roomId, eventId, roomTimeline) {
+ const boxInfo = e.target.getBoundingClientRect();
+ openEmojiBoard({
+ x: boxInfo.x,
+ y: boxInfo.y,
+ detail: e.detail,
+ }, (emoji) => {
+ toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
+ e.target.click();
+ });
+}
+
+let wasAtBottom = true;
+function RoomViewContent({
+ roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+ const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
+ const [onStateUpdate, updateState] = useState(null);
+ const [onPagination, setOnPagination] = useState(null);
+ const [editEvent, setEditEvent] = useState(null);
+ const mx = initMatrix.matrixClient;
+
+ function autoLoadTimeline() {
+ if (timelineScroll.isScrollable() === true) return;
+ roomTimeline.paginateBack();
+ }
+ function trySendingReadReceipt() {
+ const { room, timeline } = roomTimeline;
+ if (doesRoomHaveUnread(room) && timeline.length !== 0) {
+ mx.sendReadReceipt(timeline[timeline.length - 1]);
+ }
+ }
+
+ function onReachedTop() {
+ if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
+ roomTimeline.paginateBack();
+ }
+ function toggleOnReachedBottom(isBottom) {
+ wasAtBottom = isBottom;
+ if (!isBottom) return;
+ trySendingReadReceipt();
+ }
+
+ const updatePAG = (canPagMore) => {
+ if (!canPagMore) {
+ setIsReachedTimelineEnd(true);
+ } else {
+ setOnPagination({});
+ autoLoadTimeline();
+ }
+ };
+ // force update RoomTimeline on cons.events.roomTimeline.EVENT
+ const updateRT = () => {
+ if (wasAtBottom) {
+ trySendingReadReceipt();
+ }
+ updateState({});
+ };
+
+ useEffect(() => {
+ setIsReachedTimelineEnd(false);
+ wasAtBottom = true;
+ }, [roomId]);
+ useEffect(() => trySendingReadReceipt(), [roomTimeline]);
+
+ // init room setup completed.
+ // listen for future. setup stateUpdate listener.
+ useEffect(() => {
+ roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
+ roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
+ viewEvent.on('reached-top', onReachedTop);
+ viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom);
+
+ return () => {
+ roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
+ roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
+ viewEvent.removeListener('reached-top', onReachedTop);
+ viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom);
+ };
+ }, [roomTimeline, isReachedTimelineEnd, onPagination]);
+
+ useLayoutEffect(() => {
+ timelineScroll.reachBottom();
+ autoLoadTimeline();
+ }, [roomTimeline]);
+
+ useLayoutEffect(() => {
+ if (onPagination === null) return;
+ timelineScroll.tryRestoringScroll();
+ }, [onPagination]);
+
+ useEffect(() => {
+ if (onStateUpdate === null) return;
+ if (wasAtBottom) timelineScroll.reachBottom();
+ }, [onStateUpdate]);
+
+ let prevMEvent = null;
+ function genMessage(mEvent) {
+ const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel;
+ const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
+
+ const isContentOnly = (
+ prevMEvent !== null
+ && prevMEvent.getType() !== 'm.room.member'
+ && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
+ && prevMEvent.getSender() === mEvent.getSender()
+ );
+
+ let content = mEvent.getContent().body;
+ if (typeof content === 'undefined') return null;
+ let reply = null;
+ let reactions = null;
+ let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html';
+ const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
+ const isEdited = roomTimeline.editedTimeline.has(mEvent.getId());
+ const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId());
+
+ if (isReply) {
+ const parsedContent = parseReply(content);
+ if (parsedContent !== null) {
+ const c = roomTimeline.room.currentState;
+ const displayNameToUserIds = c.getUserIdsWithDisplayName(parsedContent.displayName);
+ const ID = parsedContent.userId || displayNameToUserIds[0];
+ reply = {
+ color: colorMXID(ID || parsedContent.displayName),
+ to: parsedContent.displayName || getUsername(parsedContent.userId),
+ content: parsedContent.replyContent,
+ };
+ content = parsedContent.content;
+ }
+ }
+
+ if (isEdited) {
+ const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
+ const latestEdited = editedList[editedList.length - 1];
+ if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null;
+ const latestEditBody = latestEdited.getContent()['m.new_content'].body;
+ const parsedEditedContent = parseReply(latestEditBody);
+ isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html';
+ if (parsedEditedContent === null) {
+ content = latestEditBody;
+ } else {
+ content = parsedEditedContent.content;
+ }
+ }
+
+ if (haveReactions) {
+ reactions = [];
+ roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => {
+ if (rEvent.getRelation() === null) return;
+ function alreadyHaveThisReaction(rE) {
+ for (let i = 0; i < reactions.length; i += 1) {
+ if (reactions[i].key === rE.getRelation().key) return true;
+ }
+ return false;
+ }
+ if (alreadyHaveThisReaction(rEvent)) {
+ for (let i = 0; i < reactions.length; i += 1) {
+ if (reactions[i].key === rEvent.getRelation().key) {
+ reactions[i].users.push(rEvent.getSender());
+ if (reactions[i].isActive !== true) {
+ const myUserId = initMatrix.matrixClient.getUserId();
+ reactions[i].isActive = rEvent.getSender() === myUserId;
+ if (reactions[i].isActive) reactions[i].id = rEvent.getId();
+ }
+ break;
+ }
+ }
+ } else {
+ reactions.push({
+ id: rEvent.getId(),
+ key: rEvent.getRelation().key,
+ users: [rEvent.getSender()],
+ isActive: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
+ });
+ }
+ });
+ }
+
+ const senderMXIDColor = colorMXID(mEvent.sender.userId);
+ const userAvatar = isContentOnly ? null : (
+ <Avatar
+ imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
+ text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
+ bgColor={senderMXIDColor}
+ size="small"
+ />
+ );
+ const userHeader = isContentOnly ? null : (
+ <MessageHeader
+ userId={mEvent.sender.userId}
+ name={getUsernameOfRoomMember(mEvent.sender)}
+ color={senderMXIDColor}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ />
+ );
+ const userReply = reply === null ? null : (
+ <MessageReply
+ name={reply.to}
+ color={reply.color}
+ content={reply.content}
+ />
+ );
+ const userContent = (
+ <MessageContent
+ isMarkdown={isMarkdown}
+ content={isMedia(mEvent) ? genMediaContent(mEvent) : content}
+ isEdited={isEdited}
+ />
+ );
+ const userReactions = reactions === null ? null : (
+ <MessageReactionGroup>
+ {
+ reactions.map((reaction) => (
+ <MessageReaction
+ key={reaction.id}
+ reaction={reaction.key}
+ users={reaction.users}
+ isActive={reaction.isActive}
+ onClick={() => {
+ toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline);
+ }}
+ />
+ ))
+ }
+ <IconButton
+ onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
+ src={EmojiAddIC}
+ size="extra-small"
+ tooltip="Add reaction"
+ />
+ </MessageReactionGroup>
+ );
+ const userOptions = (
+ <MessageOptions>
+ <IconButton
+ onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
+ src={EmojiAddIC}
+ size="extra-small"
+ tooltip="Add reaction"
+ />
+ <IconButton
+ onClick={() => {
+ viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
+ }}
+ src={ReplyArrowIC}
+ size="extra-small"
+ tooltip="Reply"
+ />
+ {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
+ <IconButton
+ onClick={() => setEditEvent(mEvent)}
+ src={PencilIC}
+ size="extra-small"
+ tooltip="Edit"
+ />
+ )}
+ <ContextMenu
+ content={() => (
+ <>
+ <MenuHeader>Options</MenuHeader>
+ <MenuItem
+ iconSrc={EmojiAddIC}
+ onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
+ >
+ Add reaction
+ </MenuItem>
+ <MenuItem
+ iconSrc={ReplyArrowIC}
+ onClick={() => {
+ viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
+ }}
+ >
+ Reply
+ </MenuItem>
+ {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
+ <MenuItem iconSrc={PencilIC} onClick={() => setEditEvent(mEvent)}>Edit</MenuItem>
+ )}
+ <MenuItem
+ iconSrc={TickMarkIC}
+ onClick={() => openReadReceipts(roomId, mEvent.getId())}
+ >
+ Read receipts
+ </MenuItem>
+ {(canIRedact || mEvent.getSender() === mx.getUserId()) && (
+ <>
+ <MenuBorder />
+ <MenuItem
+ variant="danger"
+ iconSrc={BinIC}
+ onClick={() => {
+ if (window.confirm('Are you sure you want to delete this event')) {
+ redactEvent(roomId, mEvent.getId());
+ }
+ }}
+ >
+ Delete
+ </MenuItem>
+ </>
+ )}
+ </>
+ )}
+ render={(toggleMenu) => (
+ <IconButton
+ onClick={toggleMenu}
+ src={VerticalMenuIC}
+ size="extra-small"
+ tooltip="Options"
+ />
+ )}
+ />
+ </MessageOptions>
+ );
+
+ const isEditingEvent = editEvent?.getId() === mEvent.getId();
+ const myMessageEl = (
+ <Message
+ key={mEvent.getId()}
+ avatar={userAvatar}
+ header={userHeader}
+ reply={userReply}
+ content={editEvent !== null && isEditingEvent ? null : userContent}
+ editContent={editEvent !== null && isEditingEvent ? (
+ <MessageEdit
+ content={content}
+ onSave={(newBody) => {
+ if (newBody !== content) {
+ initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
+ }
+ setEditEvent(null);
+ }}
+ onCancel={() => setEditEvent(null)}
+ />
+ ) : null}
+ reactions={userReactions}
+ options={editEvent !== null && isEditingEvent ? null : userOptions}
+ />
+ );
+ return myMessageEl;
+ }
+
+ function renderMessage(mEvent) {
+ if (mEvent.getType() === 'm.room.create') return genRoomIntro(mEvent, roomTimeline);
+ if (
+ mEvent.getType() !== 'm.room.message'
+ && mEvent.getType() !== 'm.room.encrypted'
+ && mEvent.getType() !== 'm.room.member'
+ && mEvent.getType() !== 'm.sticker'
+ ) return false;
+ if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
+
+ // ignore if message is deleted
+ if (mEvent.isRedacted()) return false;
+
+ let divider = null;
+ if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
+ divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
+ }
+
+ if (mEvent.getType() !== 'm.room.member') {
+ const messageComp = genMessage(mEvent);
+ prevMEvent = mEvent;
+ return (
+ <React.Fragment key={`box-${mEvent.getId()}`}>
+ {divider}
+ {messageComp}
+ </React.Fragment>
+ );
+ }
+
+ prevMEvent = mEvent;
+ const timelineChange = parseTimelineChange(mEvent);
+ if (timelineChange === null) return null;
+ return (
+ <React.Fragment key={`box-${mEvent.getId()}`}>
+ {divider}
+ <TimelineChange
+ key={mEvent.getId()}
+ variant={timelineChange.variant}
+ content={timelineChange.content}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ />
+ </React.Fragment>
+ );
+ }
+
+ return (
+ <div className="room-view__content">
+ <div className="timeline__wrapper">
+ { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() }
+ { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genRoomIntro(undefined, roomTimeline)}
+ { roomTimeline.timeline.map(renderMessage) }
+ </div>
+ </div>
+ );
+}
+RoomViewContent.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ timelineScroll: PropTypes.shape({}).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewContent;
--- /dev/null
+.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
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewFloating.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
+
+import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
+
+import { getUsersActionJsx } from './common';
+
+function RoomViewFloating({
+ roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+ const [reachedBottom, setReachedBottom] = useState(true);
+ const [typingMembers, setTypingMembers] = useState(new Set());
+ const mx = initMatrix.matrixClient;
+
+ function isSomeoneTyping(members) {
+ const m = members;
+ m.delete(mx.getUserId());
+ if (m.size === 0) return false;
+ return true;
+ }
+
+ function getTypingMessage(members) {
+ const userIds = members;
+ userIds.delete(mx.getUserId());
+ return getUsersActionJsx(roomId, [...userIds], 'typing...');
+ }
+
+ function updateTyping(members) {
+ setTypingMembers(members);
+ }
+
+ useEffect(() => {
+ setReachedBottom(true);
+ setTypingMembers(new Set());
+ viewEvent.on('toggle-reached-bottom', setReachedBottom);
+ return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
+ }, [roomId]);
+
+ useEffect(() => {
+ roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
+ return () => {
+ roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
+ };
+ }, [roomTimeline]);
+
+ return (
+ <>
+ <div className={`room-view__typing${isSomeoneTyping(typingMembers) ? ' room-view__typing--open' : ''}`}>
+ <div className="bouncingLoader"><div /></div>
+ <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
+ </div>
+ <div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}>
+ <IconButton
+ onClick={() => {
+ timelineScroll.enableSmoothScroll();
+ timelineScroll.reachBottom();
+ timelineScroll.disableSmoothScroll();
+ }}
+ src={ChevronBottomIC}
+ tooltip="Scroll to Bottom"
+ />
+ </div>
+ </>
+ );
+}
+RoomViewFloating.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ timelineScroll: PropTypes.shape({
+ reachBottom: PropTypes.func,
+ }).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewFloating;
--- /dev/null
+.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
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import initMatrix from '../../../client/initMatrix';
+import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation';
+import * as roomActions from '../../../client/action/room';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
+import Header, { TitleWrapper } from '../../atoms/header/Header';
+import Avatar from '../../atoms/avatar/Avatar';
+import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
+
+import UserIC from '../../../../public/res/ic/outlined/user.svg';
+import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
+import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
+import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
+
+function RoomViewHeader({ roomId }) {
+ const mx = initMatrix.matrixClient;
+ const isDM = initMatrix.roomList.directs.has(roomId);
+ let avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
+ avatarSrc = isDM ? mx.getRoom(roomId).getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop') : avatarSrc;
+ const roomName = mx.getRoom(roomId).name;
+ const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+
+ return (
+ <Header>
+ <Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomId)} size="small" />
+ <TitleWrapper>
+ <Text variant="h2">{roomName}</Text>
+ { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
+ </TitleWrapper>
+ <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
+ <ContextMenu
+ placement="bottom"
+ content={(toogleMenu) => (
+ <>
+ <MenuHeader>Options</MenuHeader>
+ {/* <MenuBorder /> */}
+ <MenuItem
+ iconSrc={AddUserIC}
+ onClick={() => {
+ openInviteUser(roomId); toogleMenu();
+ }}
+ >
+ Invite
+ </MenuItem>
+ <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId)}>Leave</MenuItem>
+ </>
+ )}
+ render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
+ />
+ </Header>
+ );
+}
+RoomViewHeader.propTypes = {
+ roomId: PropTypes.string.isRequired,
+};
+
+export default RoomViewHeader;
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './RoomViewInput.scss';
+
+import TextareaAutosize from 'react-autosize-textarea';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import settings from '../../../client/state/settings';
+import { openEmojiBoard } from '../../../client/action/navigation';
+import { bytesToSize } from '../../../util/common';
+import { getUsername } from '../../../util/matrixUtil';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import RawIcon from '../../atoms/system-icons/RawIcon';
+import IconButton from '../../atoms/button/IconButton';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import { MessageReply } from '../../molecules/message/Message';
+
+import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
+import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
+import SendIC from '../../../../public/res/ic/outlined/send.svg';
+import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
+import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
+import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
+import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg';
+import FileIC from '../../../../public/res/ic/outlined/file.svg';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+
+const CMD_REGEX = /(\/|>[#*@]|:|@)(\S*)$/;
+let isTyping = false;
+let isCmdActivated = false;
+let cmdCursorPos = null;
+function RoomViewInput({
+ roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+ const [attachment, setAttachment] = useState(null);
+ const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
+ const [replyTo, setReplyTo] = useState(null);
+
+ const textAreaRef = useRef(null);
+ const inputBaseRef = useRef(null);
+ const uploadInputRef = useRef(null);
+ const uploadProgressRef = useRef(null);
+ const rightOptionsRef = useRef(null);
+ const escBtnRef = useRef(null);
+
+ const TYPING_TIMEOUT = 5000;
+ const mx = initMatrix.matrixClient;
+ const { roomsInput } = initMatrix;
+
+ function requestFocusInput() {
+ if (textAreaRef === null) return;
+ textAreaRef.current.focus();
+ }
+
+ useEffect(() => {
+ settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
+ viewEvent.on('focus_msg_input', requestFocusInput);
+ return () => {
+ settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
+ viewEvent.removeListener('focus_msg_input', requestFocusInput);
+ };
+ }, []);
+
+ const sendIsTyping = (isT) => {
+ mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
+ isTyping = isT;
+
+ if (isT === true) {
+ setTimeout(() => {
+ if (isTyping) sendIsTyping(false);
+ }, TYPING_TIMEOUT);
+ }
+ };
+
+ function uploadingProgress(myRoomId, { loaded, total }) {
+ if (myRoomId !== roomId) return;
+ const progressPer = Math.round((loaded * 100) / total);
+ uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
+ inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
+ }
+ function clearAttachment(myRoomId) {
+ if (roomId !== myRoomId) return;
+ setAttachment(null);
+ inputBaseRef.current.style.backgroundImage = 'unset';
+ uploadInputRef.current.value = null;
+ }
+
+ function rightOptionsA11Y(A11Y) {
+ const rightOptions = rightOptionsRef.current.children;
+ for (let index = 0; index < rightOptions.length; index += 1) {
+ rightOptions[index].disabled = !A11Y;
+ }
+ }
+
+ function activateCmd(prefix) {
+ isCmdActivated = true;
+ requestAnimationFrame(() => {
+ inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-positive)';
+ escBtnRef.current.style.display = 'block';
+ });
+ rightOptionsA11Y(false);
+ viewEvent.emit('cmd_activate', prefix);
+ }
+ function deactivateCmd() {
+ if (inputBaseRef.current !== null) {
+ requestAnimationFrame(() => {
+ inputBaseRef.current.style.boxShadow = 'var(--bs-surface-border)';
+ escBtnRef.current.style.display = 'none';
+ });
+ rightOptionsA11Y(true);
+ }
+ isCmdActivated = false;
+ cmdCursorPos = null;
+ }
+ function deactivateCmdAndEmit() {
+ deactivateCmd();
+ viewEvent.emit('cmd_deactivate');
+ }
+ function errorCmd() {
+ requestAnimationFrame(() => {
+ inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-danger)';
+ });
+ }
+ function setCursorPosition(pos) {
+ setTimeout(() => {
+ textAreaRef.current.focus();
+ textAreaRef.current.setSelectionRange(pos, pos);
+ }, 0);
+ }
+ function replaceCmdWith(msg, cursor, replacement) {
+ if (msg === null) return null;
+ const targetInput = msg.slice(0, cursor);
+ const cmdParts = targetInput.match(CMD_REGEX);
+ const leadingInput = msg.slice(0, cmdParts.index);
+ if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
+ return leadingInput + replacement + msg.slice(cursor);
+ }
+ function firedCmd(cmdData) {
+ const msg = textAreaRef.current.value;
+ textAreaRef.current.value = replaceCmdWith(
+ msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
+ );
+ deactivateCmd();
+ }
+
+ function focusInput() {
+ if (settings.isTouchScreenDevice) return;
+ textAreaRef.current.focus();
+ }
+
+ function setUpReply(userId, eventId, content) {
+ setReplyTo({ userId, eventId, content });
+ roomsInput.setReplyTo(roomId, { userId, eventId, content });
+ focusInput();
+ }
+
+ useEffect(() => {
+ roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
+ roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
+ roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
+ viewEvent.on('cmd_error', errorCmd);
+ viewEvent.on('cmd_fired', firedCmd);
+ viewEvent.on('reply_to', setUpReply);
+ if (textAreaRef?.current !== null) {
+ isTyping = false;
+ focusInput();
+ textAreaRef.current.value = roomsInput.getMessage(roomId);
+ setAttachment(roomsInput.getAttachment(roomId));
+ setReplyTo(roomsInput.getReplyTo(roomId));
+ }
+ return () => {
+ roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
+ roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
+ roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
+ viewEvent.removeListener('cmd_error', errorCmd);
+ viewEvent.removeListener('cmd_fired', firedCmd);
+ viewEvent.removeListener('reply_to', setUpReply);
+ if (isCmdActivated) deactivateCmd();
+ if (textAreaRef?.current === null) return;
+
+ const msg = textAreaRef.current.value;
+ inputBaseRef.current.style.backgroundImage = 'unset';
+ if (msg.trim() === '') {
+ roomsInput.setMessage(roomId, '');
+ return;
+ }
+ roomsInput.setMessage(roomId, msg);
+ };
+ }, [roomId]);
+
+ async function sendMessage() {
+ const msgBody = textAreaRef.current.value;
+ if (roomsInput.isSending(roomId)) return;
+ if (msgBody.trim() === '' && attachment === null) return;
+ sendIsTyping(false);
+
+ roomsInput.setMessage(roomId, msgBody);
+ if (attachment !== null) {
+ roomsInput.setAttachment(roomId, attachment);
+ }
+ textAreaRef.current.disabled = true;
+ textAreaRef.current.style.cursor = 'not-allowed';
+ await roomsInput.sendInput(roomId);
+ textAreaRef.current.disabled = false;
+ textAreaRef.current.style.cursor = 'unset';
+ focusInput();
+
+ textAreaRef.current.value = roomsInput.getMessage(roomId);
+ timelineScroll.reachBottom();
+ viewEvent.emit('message_sent');
+ textAreaRef.current.style.height = 'unset';
+ if (replyTo !== null) setReplyTo(null);
+ }
+
+ function processTyping(msg) {
+ const isEmptyMsg = msg === '';
+
+ if (isEmptyMsg && isTyping) {
+ sendIsTyping(false);
+ return;
+ }
+ if (!isEmptyMsg && !isTyping) {
+ sendIsTyping(true);
+ }
+ }
+
+ function getCursorPosition() {
+ return textAreaRef.current.selectionStart;
+ }
+
+ function recognizeCmd(rawInput) {
+ const cursor = getCursorPosition();
+ const targetInput = rawInput.slice(0, cursor);
+
+ const cmdParts = targetInput.match(CMD_REGEX);
+ if (cmdParts === null) {
+ if (isCmdActivated) deactivateCmdAndEmit();
+ return;
+ }
+ const cmdPrefix = cmdParts[1];
+ const cmdSlug = cmdParts[2];
+
+ if (cmdPrefix === ':') {
+ // skip emoji autofill command if link is suspected.
+ const checkForLink = targetInput.slice(0, cmdParts.index);
+ if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
+ deactivateCmdAndEmit();
+ return;
+ }
+ }
+
+ cmdCursorPos = cursor;
+ if (cmdSlug === '') {
+ activateCmd(cmdPrefix);
+ return;
+ }
+ if (!isCmdActivated) activateCmd(cmdPrefix);
+ requestAnimationFrame(() => {
+ inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-caution)';
+ });
+ viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
+ }
+
+ function handleMsgTyping(e) {
+ const msg = e.target.value;
+ recognizeCmd(e.target.value);
+ if (!isCmdActivated) processTyping(msg);
+ }
+
+ function handleKeyDown(e) {
+ if (e.keyCode === 13 && e.shiftKey === false) {
+ e.preventDefault();
+
+ if (isCmdActivated) {
+ viewEvent.emit('cmd_exe');
+ } else sendMessage();
+ }
+ if (e.keyCode === 27 && isCmdActivated) {
+ deactivateCmdAndEmit();
+ e.preventDefault();
+ }
+ }
+
+ function addEmoji(emoji) {
+ textAreaRef.current.value += emoji.unicode;
+ }
+
+ function handleUploadClick() {
+ if (attachment === null) uploadInputRef.current.click();
+ else {
+ roomsInput.cancelAttachment(roomId);
+ }
+ }
+ function uploadFileChange(e) {
+ const file = e.target.files.item(0);
+ setAttachment(file);
+ if (file !== null) roomsInput.setAttachment(roomId, file);
+ }
+
+ function renderInputs() {
+ return (
+ <>
+ <div className={`room-input__option-container${attachment === null ? '' : ' room-attachment__option'}`}>
+ <input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
+ <IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
+ </div>
+ <div ref={inputBaseRef} className="room-input__input-container">
+ {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
+ <ScrollView autoHide>
+ <Text className="room-input__textarea-wrapper">
+ <TextareaAutosize
+ ref={textAreaRef}
+ onChange={handleMsgTyping}
+ onResize={() => timelineScroll.autoReachBottom()}
+ onKeyDown={handleKeyDown}
+ placeholder="Send a message..."
+ />
+ </Text>
+ </ScrollView>
+ {isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
+ <button ref={escBtnRef} tabIndex="-1" onClick={deactivateCmdAndEmit} className="btn-cmd-esc" type="button"><Text variant="b3">ESC</Text></button>
+ </div>
+ <div ref={rightOptionsRef} className="room-input__option-container">
+ <IconButton
+ onClick={(e) => {
+ const boxInfo = e.target.getBoundingClientRect();
+ openEmojiBoard({
+ x: boxInfo.x + (document.dir === 'rtl' ? -80 : 80),
+ y: boxInfo.y - 250,
+ detail: e.detail,
+ }, addEmoji);
+ }}
+ tooltip="Emoji"
+ src={EmojiIC}
+ />
+ <IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
+ </div>
+ </>
+ );
+ }
+
+ function attachFile() {
+ const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
+ return (
+ <div className="room-attachment">
+ <div className={`room-attachment__preview${fileType !== 'image' ? ' room-attachment__icon' : ''}`}>
+ {fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
+ {fileType === 'video' && <RawIcon src={VLCIC} />}
+ {fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
+ {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
+ </div>
+ <div className="room-attachment__info">
+ <Text variant="b1">{attachment.name}</Text>
+ <Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
+ </div>
+ </div>
+ );
+ }
+
+ function attachReply() {
+ return (
+ <div className="room-reply">
+ <IconButton
+ onClick={() => {
+ roomsInput.cancelReplyTo(roomId);
+ setReplyTo(null);
+ }}
+ src={CrossIC}
+ tooltip="Cancel reply"
+ size="extra-small"
+ />
+ <MessageReply
+ userId={replyTo.userId}
+ name={getUsername(replyTo.userId)}
+ color={colorMXID(replyTo.userId)}
+ content={replyTo.content}
+ />
+ </div>
+ );
+ }
+
+ return (
+ <>
+ { replyTo !== null && attachReply()}
+ { attachment !== null && attachFile() }
+ <form className="room-input" onSubmit={(e) => { e.preventDefault(); }}>
+ {
+ roomTimeline.room.isSpaceRoom()
+ ? <Text className="room-input__space" variant="b1">Spaces are yet to be implemented</Text>
+ : renderInputs()
+ }
+ </form>
+ </>
+ );
+}
+RoomViewInput.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ timelineScroll: PropTypes.shape({
+ reachBottom: PropTypes.func,
+ autoReachBottom: PropTypes.func,
+ tryRestoringScroll: PropTypes.func,
+ enableSmoothScroll: PropTypes.func,
+ disableSmoothScroll: PropTypes.func,
+ }).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default RoomViewInput;
--- /dev/null
+.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
--- /dev/null
+import React from 'react';
+
+import initMatrix from '../../../client/initMatrix';
+import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
+
+function getTimelineJSXMessages() {
+ return {
+ join(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' joined the room'}
+ </>
+ );
+ },
+ leave(user, reason) {
+ const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
+ return (
+ <>
+ <b>{user}</b>
+ {' left the room'}
+ {reasonMsg}
+ </>
+ );
+ },
+ invite(inviter, user) {
+ return (
+ <>
+ <b>{inviter}</b>
+ {' invited '}
+ <b>{user}</b>
+ </>
+ );
+ },
+ cancelInvite(inviter, user) {
+ return (
+ <>
+ <b>{inviter}</b>
+ {' canceled '}
+ <b>{user}</b>
+ {'\'s invite'}
+ </>
+ );
+ },
+ rejectInvite(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' rejected the invitation'}
+ </>
+ );
+ },
+ kick(actor, user, reason) {
+ const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
+ return (
+ <>
+ <b>{actor}</b>
+ {' kicked '}
+ <b>{user}</b>
+ {reasonMsg}
+ </>
+ );
+ },
+ ban(actor, user, reason) {
+ const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
+ return (
+ <>
+ <b>{actor}</b>
+ {' banned '}
+ <b>{user}</b>
+ {reasonMsg}
+ </>
+ );
+ },
+ unban(actor, user) {
+ return (
+ <>
+ <b>{actor}</b>
+ {' unbanned '}
+ <b>{user}</b>
+ </>
+ );
+ },
+ avatarSets(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' set the avatar'}
+ </>
+ );
+ },
+ avatarChanged(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' changed the avatar'}
+ </>
+ );
+ },
+ avatarRemoved(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' removed the avatar'}
+ </>
+ );
+ },
+ nameSets(user, newName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' set the display name to '}
+ <b>{newName}</b>
+ </>
+ );
+ },
+ nameChanged(user, newName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' changed the display name to '}
+ <b>{newName}</b>
+ </>
+ );
+ },
+ nameRemoved(user, lastName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' removed the display name '}
+ <b>{lastName}</b>
+ </>
+ );
+ },
+ };
+}
+
+function getUsersActionJsx(roomId, userIds, actionStr) {
+ const room = initMatrix.matrixClient.getRoom(roomId);
+ const getUserDisplayName = (userId) => {
+ if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
+ return getUsername(userId);
+ };
+ const getUserJSX = (userId) => <b>{getUserDisplayName(userId)}</b>;
+ if (!Array.isArray(userIds)) return 'Idle';
+ if (userIds.length === 0) return 'Idle';
+ const MAX_VISIBLE_COUNT = 3;
+
+ const u1Jsx = getUserJSX(userIds[0]);
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
+
+ const u2Jsx = getUserJSX(userIds[1]);
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
+
+ const u3Jsx = getUserJSX(userIds[2]);
+ if (userIds.length === 3) {
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
+ }
+
+ const othersCount = userIds.length - MAX_VISIBLE_COUNT;
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}</>;
+}
+
+function parseReply(rawContent) {
+ if (rawContent.indexOf('>') !== 0) return null;
+ let content = rawContent.slice(rawContent.indexOf('<') + 1);
+ const user = content.slice(0, content.indexOf('>'));
+
+ content = content.slice(content.indexOf('>') + 2);
+ const replyContent = content.slice(0, content.indexOf('\n\n'));
+ content = content.slice(content.indexOf('\n\n') + 2);
+
+ if (user === '') return null;
+
+ const isUserId = user.match(/^@.+:.+/);
+
+ return {
+ userId: isUserId ? user : null,
+ displayName: isUserId ? null : user,
+ replyContent,
+ content,
+ };
+}
+
+function parseTimelineChange(mEvent) {
+ const tJSXMsgs = getTimelineJSXMessages();
+ const makeReturnObj = (variant, content) => ({
+ variant,
+ content,
+ });
+ const content = mEvent.getContent();
+ const prevContent = mEvent.getPrevContent();
+ const sender = mEvent.getSender();
+ const senderName = getUsername(sender);
+ const userName = getUsername(mEvent.getStateKey());
+
+ switch (content.membership) {
+ case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
+ case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
+ case 'join':
+ if (prevContent.membership === 'join') {
+ if (content.displayname !== prevContent.displayname) {
+ if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
+ if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
+ return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
+ }
+ if (content.avatar_url !== prevContent.avatar_url) {
+ if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
+ if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
+ return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
+ }
+ return null;
+ }
+ return makeReturnObj('join', tJSXMsgs.join(senderName));
+ case 'leave':
+ if (sender === mEvent.getStateKey()) {
+ switch (prevContent.membership) {
+ case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
+ default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
+ }
+ }
+ switch (prevContent.membership) {
+ case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
+ case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
+ // sender is not target and made the target leave,
+ // if not from invite/ban then this is a kick
+ default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
+ }
+ default: return null;
+ }
+}
+
+function scrollToBottom(ref) {
+ const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight;
+ // eslint-disable-next-line no-param-reassign
+ ref.current.scrollTop = maxScrollTop;
+}
+
+function isAtBottom(ref) {
+ const { scrollHeight, scrollTop, offsetHeight } = ref.current;
+ const scrollUptoBottom = scrollTop + offsetHeight;
+
+ // scroll view have to div inside div which contains messages
+ const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild;
+ const lastChildHeight = lastMessage.offsetHeight;
+
+ // auto scroll to bottom even if user has EXTRA_SPACE left to scroll
+ const EXTRA_SPACE = 48;
+
+ if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) {
+ return true;
+ }
+ return false;
+}
+
+function autoScrollToBottom(ref) {
+ if (isAtBottom(ref)) scrollToBottom(ref);
+}
+
+export {
+ getTimelineJSXMessages,
+ getUsersActionJsx,
+ parseReply,
+ parseTimelineChange,
+ scrollToBottom,
+ isAtBottom,
+ autoScrollToBottom,
+};
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';
<div className="navigation__wrapper">
<Navigation />
</div>
- <div className="channel__wrapper">
- <Channel />
+ <div className="room__wrapper">
+ <Room />
</div>
<Windows />
<Dialogs />
.navigation__wrapper {
width: var(--navigation-width);
}
-.channel__wrapper {
+.room__wrapper {
flex: 1;
min-width: 0;
background-color: var(--bg-surface);
});
}
-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,
});
}
selectRoom,
togglePeopleDrawer,
openInviteList,
- openPublicChannels,
- openCreateChannel,
+ openPublicRooms,
+ openCreateRoom,
openInviteUser,
openSettings,
openEmojiBoard,
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',
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',
[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);