Renamed channels to rooms (#30)
This commit is contained in:
138
src/app/organisms/room/PeopleDrawer.jsx
Normal file
138
src/app/organisms/room/PeopleDrawer.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './PeopleDrawer.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { openInviteUser } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
|
||||
|
||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||
|
||||
function getPowerLabel(powerLevel) {
|
||||
switch (powerLevel) {
|
||||
case 100:
|
||||
return 'Admin';
|
||||
case 50:
|
||||
return 'Mod';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function compare(m1, m2) {
|
||||
let aName = m1.name;
|
||||
let bName = m2.name;
|
||||
|
||||
// remove "#" from the room name
|
||||
// To ignore it in sorting
|
||||
aName = aName.replaceAll('#', '');
|
||||
bName = bName.replaceAll('#', '');
|
||||
|
||||
if (aName.toLowerCase() < bName.toLowerCase()) {
|
||||
return -1;
|
||||
}
|
||||
if (aName.toLowerCase() > bName.toLowerCase()) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function sortByPowerLevel(m1, m2) {
|
||||
let pl1 = String(m1.powerLevel);
|
||||
let pl2 = String(m2.powerLevel);
|
||||
|
||||
if (pl1 === '100') pl1 = '90.9';
|
||||
if (pl2 === '100') pl2 = '90.9';
|
||||
|
||||
if (pl1.toLowerCase() > pl2.toLowerCase()) {
|
||||
return -1;
|
||||
}
|
||||
if (pl1.toLowerCase() < pl2.toLowerCase()) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function PeopleDrawer({ roomId }) {
|
||||
const PER_PAGE_MEMBER = 50;
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
const totalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
|
||||
const [memberList, updateMemberList] = useState([]);
|
||||
let isRoomChanged = false;
|
||||
|
||||
function loadMorePeople() {
|
||||
updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER));
|
||||
room.loadMembersIfNeeded().then(() => {
|
||||
if (isRoomChanged) return;
|
||||
const newTotalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
|
||||
updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER));
|
||||
});
|
||||
|
||||
return () => {
|
||||
isRoomChanged = true;
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
return (
|
||||
<div className="people-drawer">
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1">
|
||||
People
|
||||
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
<IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} />
|
||||
</Header>
|
||||
<div className="people-drawer__content-wrapper">
|
||||
<div className="people-drawer__scrollable">
|
||||
<ScrollView autoHide>
|
||||
<div className="people-drawer__content">
|
||||
{
|
||||
memberList.map((member) => (
|
||||
<PeopleSelector
|
||||
key={member.userId}
|
||||
onClick={() => alert('Viewing profile is yet to be implemented')}
|
||||
avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
|
||||
name={getUsernameOfRoomMember(member)}
|
||||
color={colorMXID(member.userId)}
|
||||
peopleRole={getPowerLabel(member.powerLevel)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<div className="people-drawer__load-more">
|
||||
{
|
||||
memberList.length !== totalMemberList.length && (
|
||||
<Button onClick={loadMorePeople}>View more</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
<div className="people-drawer__sticky">
|
||||
<form onSubmit={(e) => e.preventDefault()} className="people-search">
|
||||
<Input type="text" placeholder="Search" required />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PeopleDrawer.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default PeopleDrawer;
|
||||
75
src/app/organisms/room/PeopleDrawer.scss
Normal file
75
src/app/organisms/room/PeopleDrawer.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
.people-drawer-flexBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.people-drawer-flexItem {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.people-drawer {
|
||||
@extend .people-drawer-flexBox;
|
||||
width: var(--people-drawer-width);
|
||||
background-color: var(--bg-surface-low);
|
||||
border-left: 1px solid var(--bg-surface-border);
|
||||
|
||||
[dir=rtl] & {
|
||||
border: {
|
||||
left: none;
|
||||
right: 1px solid var(--bg-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&__member-count {
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
|
||||
&__content-wrapper {
|
||||
@extend .people-drawer-flexItem;
|
||||
@extend .people-drawer-flexBox;
|
||||
}
|
||||
|
||||
&__scrollable {
|
||||
@extend .people-drawer-flexItem;
|
||||
}
|
||||
|
||||
&__sticky {
|
||||
display: none;
|
||||
|
||||
& .people-search {
|
||||
min-height: 48px;
|
||||
|
||||
margin: 0 var(--sp-normal);
|
||||
|
||||
position: relative;
|
||||
bottom: var(--sp-normal);
|
||||
|
||||
& .input {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.people-drawer__content {
|
||||
padding-top: var(--sp-extra-tight);
|
||||
padding-bottom: calc( var(--sp-extra-tight) + var(--sp-normal));
|
||||
}
|
||||
.people-drawer__load-more {
|
||||
padding: var(--sp-normal);
|
||||
padding: {
|
||||
bottom: 0;
|
||||
right: var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
[dir=rtl] & {
|
||||
padding-right: var(--sp-normal);
|
||||
padding-left: var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
& .btn-surface {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
40
src/app/organisms/room/Room.jsx
Normal file
40
src/app/organisms/room/Room.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './Room.scss';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import Welcome from '../welcome/Welcome';
|
||||
import RoomView from './RoomView';
|
||||
import PeopleDrawer from './PeopleDrawer';
|
||||
|
||||
function Room() {
|
||||
const [selectedRoomId, changeSelectedRoomId] = useState(null);
|
||||
const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible);
|
||||
useEffect(() => {
|
||||
const handleRoomSelected = (roomId) => {
|
||||
changeSelectedRoomId(roomId);
|
||||
};
|
||||
const handleDrawerToggling = (visiblity) => {
|
||||
toggleDrawerVisiblity(visiblity);
|
||||
};
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
|
||||
navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
|
||||
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
|
||||
navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (selectedRoomId === null) return <Welcome />;
|
||||
|
||||
return (
|
||||
<div className="room-container">
|
||||
<RoomView roomId={selectedRoomId} />
|
||||
{ isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Room;
|
||||
4
src/app/organisms/room/Room.scss
Normal file
4
src/app/organisms/room/Room.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.room-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
150
src/app/organisms/room/RoomView.jsx
Normal file
150
src/app/organisms/room/RoomView.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomView.scss';
|
||||
|
||||
import EventEmitter from 'events';
|
||||
|
||||
import RoomTimeline from '../../../client/state/RoomTimeline';
|
||||
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
|
||||
import RoomViewHeader from './RoomViewHeader';
|
||||
import RoomViewContent from './RoomViewContent';
|
||||
import RoomViewFloating from './RoomViewFloating';
|
||||
import RoomViewInput from './RoomViewInput';
|
||||
import RoomViewCmdBar from './RoomViewCmdBar';
|
||||
|
||||
import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
|
||||
|
||||
const viewEvent = new EventEmitter();
|
||||
|
||||
let lastScrollTop = 0;
|
||||
let lastScrollHeight = 0;
|
||||
let isReachedBottom = true;
|
||||
let isReachedTop = false;
|
||||
function RoomView({ roomId }) {
|
||||
const [roomTimeline, updateRoomTimeline] = useState(null);
|
||||
const timelineSVRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
roomTimeline?.removeInternalListeners();
|
||||
updateRoomTimeline(new RoomTimeline(roomId));
|
||||
isReachedBottom = true;
|
||||
isReachedTop = false;
|
||||
}, [roomId]);
|
||||
|
||||
const timelineScroll = {
|
||||
reachBottom() {
|
||||
scrollToBottom(timelineSVRef);
|
||||
},
|
||||
autoReachBottom() {
|
||||
autoScrollToBottom(timelineSVRef);
|
||||
},
|
||||
tryRestoringScroll() {
|
||||
const sv = timelineSVRef.current;
|
||||
const { scrollHeight } = sv;
|
||||
|
||||
if (lastScrollHeight === scrollHeight) return;
|
||||
|
||||
if (lastScrollHeight < scrollHeight) {
|
||||
sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
|
||||
} else {
|
||||
timelineScroll.reachBottom();
|
||||
}
|
||||
},
|
||||
enableSmoothScroll() {
|
||||
timelineSVRef.current.style.scrollBehavior = 'smooth';
|
||||
},
|
||||
disableSmoothScroll() {
|
||||
timelineSVRef.current.style.scrollBehavior = 'auto';
|
||||
},
|
||||
isScrollable() {
|
||||
const oHeight = timelineSVRef.current.offsetHeight;
|
||||
const sHeight = timelineSVRef.current.scrollHeight;
|
||||
if (sHeight > oHeight) return true;
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
function onTimelineScroll(e) {
|
||||
const { scrollTop, scrollHeight, offsetHeight } = e.target;
|
||||
const scrollBottom = scrollTop + offsetHeight;
|
||||
lastScrollTop = scrollTop;
|
||||
lastScrollHeight = scrollHeight;
|
||||
|
||||
const PLACEHOLDER_HEIGHT = 96;
|
||||
const PLACEHOLDER_COUNT = 3;
|
||||
|
||||
const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT;
|
||||
const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2);
|
||||
|
||||
if (!isReachedBottom && isAtBottom(timelineSVRef)) {
|
||||
isReachedBottom = true;
|
||||
viewEvent.emit('toggle-reached-bottom', true);
|
||||
}
|
||||
if (isReachedBottom && !isAtBottom(timelineSVRef)) {
|
||||
isReachedBottom = false;
|
||||
viewEvent.emit('toggle-reached-bottom', false);
|
||||
}
|
||||
// TOP of timeline
|
||||
if (scrollTop < topPagKeyPoint && isReachedTop === false) {
|
||||
isReachedTop = true;
|
||||
viewEvent.emit('reached-top');
|
||||
return;
|
||||
}
|
||||
isReachedTop = false;
|
||||
|
||||
// BOTTOM of timeline
|
||||
if (scrollBottom > bottomPagKeyPoint) {
|
||||
// TODO:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="room-view">
|
||||
<RoomViewHeader roomId={roomId} />
|
||||
<div className="room-view__content-wrapper">
|
||||
<div className="room-view__scrollable">
|
||||
<ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide>
|
||||
{roomTimeline !== null && (
|
||||
<RoomViewContent
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
timelineScroll={timelineScroll}
|
||||
viewEvent={viewEvent}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
{roomTimeline !== null && (
|
||||
<RoomViewFloating
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
timelineScroll={timelineScroll}
|
||||
viewEvent={viewEvent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{roomTimeline !== null && (
|
||||
<div className="room-view__sticky">
|
||||
<RoomViewInput
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
timelineScroll={timelineScroll}
|
||||
viewEvent={viewEvent}
|
||||
/>
|
||||
<RoomViewCmdBar
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
viewEvent={viewEvent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RoomView.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default RoomView;
|
||||
31
src/app/organisms/room/RoomView.scss
Normal file
31
src/app/organisms/room/RoomView.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
.room-view-flexBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.room-view-flexItem {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.room-view {
|
||||
@extend .room-view-flexItem;
|
||||
@extend .room-view-flexBox;
|
||||
|
||||
&__content-wrapper {
|
||||
@extend .room-view-flexItem;
|
||||
@extend .room-view-flexBox;
|
||||
}
|
||||
|
||||
&__scrollable {
|
||||
@extend .room-view-flexItem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__sticky {
|
||||
min-height: 85px;
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
}
|
||||
475
src/app/organisms/room/RoomViewCmdBar.jsx
Normal file
475
src/app/organisms/room/RoomViewCmdBar.jsx
Normal file
@@ -0,0 +1,475 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewCmdBar.scss';
|
||||
import parse from 'html-react-parser';
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { toggleMarkdown } from '../../../client/action/settings';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import {
|
||||
selectRoom,
|
||||
openCreateRoom,
|
||||
openPublicRooms,
|
||||
openInviteUser,
|
||||
openReadReceipts,
|
||||
} from '../../../client/action/navigation';
|
||||
import { emojis } from '../emoji-board/emoji';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import ContextMenu, { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
import TimelineChange from '../../molecules/message/TimelineChange';
|
||||
|
||||
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
||||
|
||||
import { getUsersActionJsx } from './common';
|
||||
|
||||
const commands = [{
|
||||
name: 'markdown',
|
||||
description: 'Toggle markdown for messages.',
|
||||
exe: () => toggleMarkdown(),
|
||||
}, {
|
||||
name: 'startDM',
|
||||
isOptions: true,
|
||||
description: 'Start direct message with user. Example: /startDM/@johndoe.matrix.org',
|
||||
exe: (roomId, searchTerm) => openInviteUser(undefined, searchTerm),
|
||||
}, {
|
||||
name: 'createRoom',
|
||||
description: 'Create new room',
|
||||
exe: () => openCreateRoom(),
|
||||
}, {
|
||||
name: 'join',
|
||||
isOptions: true,
|
||||
description: 'Join room with alias. Example: /join/#cinny:matrix.org',
|
||||
exe: (roomId, searchTerm) => openPublicRooms(searchTerm),
|
||||
}, {
|
||||
name: 'leave',
|
||||
description: 'Leave current room',
|
||||
exe: (roomId) => roomActions.leave(roomId),
|
||||
}, {
|
||||
name: 'invite',
|
||||
isOptions: true,
|
||||
description: 'Invite user to room. Example: /invite/@johndoe:matrix.org',
|
||||
exe: (roomId, searchTerm) => openInviteUser(roomId, searchTerm),
|
||||
}];
|
||||
|
||||
function CmdHelp() {
|
||||
return (
|
||||
<ContextMenu
|
||||
placement="top"
|
||||
content={(
|
||||
<>
|
||||
<MenuHeader>General command</MenuHeader>
|
||||
<Text variant="b2">/command_name</Text>
|
||||
<MenuHeader>Go-to commands</MenuHeader>
|
||||
<Text variant="b2">{'>*space_name'}</Text>
|
||||
<Text variant="b2">{'>#room_name'}</Text>
|
||||
<Text variant="b2">{'>@people_name'}</Text>
|
||||
<MenuHeader>Autofill commands</MenuHeader>
|
||||
<Text variant="b2">:emoji_name</Text>
|
||||
<Text variant="b2">@name</Text>
|
||||
</>
|
||||
)}
|
||||
render={(toggleMenu) => (
|
||||
<IconButton
|
||||
src={CmdIC}
|
||||
size="extra-small"
|
||||
onClick={toggleMenu}
|
||||
tooltip="Commands"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewCmd() {
|
||||
function renderAllCmds() {
|
||||
return commands.map((command) => (
|
||||
<SettingTile
|
||||
key={command.name}
|
||||
title={command.name}
|
||||
content={(<Text variant="b3">{command.description}</Text>)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
return (
|
||||
<ContextMenu
|
||||
maxWidth={250}
|
||||
placement="top"
|
||||
content={(
|
||||
<>
|
||||
<MenuHeader>General commands</MenuHeader>
|
||||
{renderAllCmds()}
|
||||
</>
|
||||
)}
|
||||
render={(toggleMenu) => (
|
||||
<span>
|
||||
<Button onClick={toggleMenu}><span className="text text-b3">View all</span></Button>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
|
||||
const [followingMembers, setFollowingMembers] = useState([]);
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
function handleOnMessageSent() {
|
||||
setFollowingMembers([]);
|
||||
}
|
||||
|
||||
function updateFollowingMembers() {
|
||||
const room = mx.getRoom(roomId);
|
||||
const { timeline } = room;
|
||||
const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
|
||||
const myUserId = mx.getUserId();
|
||||
setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
|
||||
}
|
||||
|
||||
useEffect(() => updateFollowingMembers(), [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
|
||||
viewEvent.on('message_sent', handleOnMessageSent);
|
||||
return () => {
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
|
||||
viewEvent.removeListener('message_sent', handleOnMessageSent);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1];
|
||||
return followingMembers.length !== 0 && (
|
||||
<TimelineChange
|
||||
variant="follow"
|
||||
content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')}
|
||||
time=""
|
||||
onClick={() => openReadReceipts(roomId, lastMEvent.getId())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
FollowingMembers.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function getCmdActivationMessage(prefix) {
|
||||
function genMessage(prime, secondary) {
|
||||
return (
|
||||
<>
|
||||
<span>{prime}</span>
|
||||
<span>{secondary}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const cmd = {
|
||||
'/': () => genMessage('General command mode activated. ', 'Type command name for suggestions.'),
|
||||
'>*': () => genMessage('Go-to command mode activated. ', 'Type space name for suggestions.'),
|
||||
'>#': () => genMessage('Go-to command mode activated. ', 'Type room name for suggestions.'),
|
||||
'>@': () => genMessage('Go-to command mode activated. ', 'Type people name for suggestions.'),
|
||||
':': () => genMessage('Emoji autofill command mode activated. ', 'Type emoji shortcut for suggestions.'),
|
||||
'@': () => genMessage('Name autofill command mode activated. ', 'Type name for suggestions.'),
|
||||
};
|
||||
return cmd[prefix]?.();
|
||||
}
|
||||
|
||||
function CmdItem({ onClick, children }) {
|
||||
return (
|
||||
<button className="cmd-item" onClick={onClick} type="button">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
CmdItem.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
|
||||
function getGenCmdSuggestions(cmdPrefix, cmds) {
|
||||
const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?';
|
||||
return cmds.map((cmd) => (
|
||||
<CmdItem
|
||||
key={cmd.name}
|
||||
onClick={() => {
|
||||
fireCmd({
|
||||
prefix: cmdPrefix,
|
||||
option,
|
||||
result: cmd,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text variant="b2">{`${cmd.name}${cmd.isOptions ? cmdOptString : ''}`}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
function getRoomsSuggestion(cmdPrefix, rooms) {
|
||||
return rooms.map((room) => (
|
||||
<CmdItem
|
||||
key={room.roomId}
|
||||
onClick={() => {
|
||||
fireCmd({
|
||||
prefix: cmdPrefix,
|
||||
result: room,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text variant="b2">{room.name}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
function getEmojiSuggestion(emPrefix, emos) {
|
||||
return emos.map((emoji) => (
|
||||
<CmdItem
|
||||
key={emoji.hexcode}
|
||||
onClick={() => fireCmd({
|
||||
prefix: emPrefix,
|
||||
result: emoji,
|
||||
})}
|
||||
>
|
||||
{
|
||||
parse(twemoji.parse(
|
||||
emoji.unicode,
|
||||
{
|
||||
attributes: () => ({
|
||||
unicode: emoji.unicode,
|
||||
shortcodes: emoji.shortcodes?.toString(),
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
<Text variant="b2">{`:${emoji.shortcode}:`}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
function getNameSuggestion(namePrefix, members) {
|
||||
return members.map((member) => (
|
||||
<CmdItem
|
||||
key={member.userId}
|
||||
onClick={() => {
|
||||
fireCmd({
|
||||
prefix: namePrefix,
|
||||
result: member,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text variant="b2">{member.name}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
const cmd = {
|
||||
'/': (cmds) => getGenCmdSuggestions(prefix, cmds),
|
||||
'>*': (spaces) => getRoomsSuggestion(prefix, spaces),
|
||||
'>#': (rooms) => getRoomsSuggestion(prefix, rooms),
|
||||
'>@': (peoples) => getRoomsSuggestion(prefix, peoples),
|
||||
':': (emos) => getEmojiSuggestion(prefix, emos),
|
||||
'@': (members) => getNameSuggestion(prefix, members),
|
||||
};
|
||||
return cmd[prefix]?.(suggestions);
|
||||
}
|
||||
|
||||
const asyncSearch = new AsyncSearch();
|
||||
let cmdPrefix;
|
||||
let cmdOption;
|
||||
function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
||||
const [cmd, setCmd] = useState(null);
|
||||
|
||||
function displaySuggestions(suggestions) {
|
||||
if (suggestions.length === 0) {
|
||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, error: 'No suggestion found.' });
|
||||
viewEvent.emit('cmd_error');
|
||||
return;
|
||||
}
|
||||
setCmd({ prefix: cmd?.prefix || cmdPrefix, suggestions, option: cmdOption });
|
||||
}
|
||||
|
||||
function processCmd(prefix, slug) {
|
||||
let searchTerm = slug;
|
||||
cmdOption = undefined;
|
||||
cmdPrefix = prefix;
|
||||
if (prefix === '/') {
|
||||
const cmdSlugParts = slug.split('/');
|
||||
[searchTerm, cmdOption] = cmdSlugParts;
|
||||
}
|
||||
if (prefix === ':') {
|
||||
if (searchTerm.length <= 3) {
|
||||
if (searchTerm.match(/^[-]?(\))$/)) searchTerm = 'smile';
|
||||
else if (searchTerm.match(/^[-]?(s|S)$/)) searchTerm = 'confused';
|
||||
else if (searchTerm.match(/^[-]?(o|O|0)$/)) searchTerm = 'astonished';
|
||||
else if (searchTerm.match(/^[-]?(\|)$/)) searchTerm = 'neutral_face';
|
||||
else if (searchTerm.match(/^[-]?(d|D)$/)) searchTerm = 'grin';
|
||||
else if (searchTerm.match(/^[-]?(\/)$/)) searchTerm = 'frown';
|
||||
else if (searchTerm.match(/^[-]?(p|P)$/)) searchTerm = 'stuck_out_tongue';
|
||||
else if (searchTerm.match(/^'[-]?(\()$/)) searchTerm = 'cry';
|
||||
else if (searchTerm.match(/^[-]?(x|X)$/)) searchTerm = 'dizzy_face';
|
||||
else if (searchTerm.match(/^[-]?(\()$/)) searchTerm = 'pleading_face';
|
||||
else if (searchTerm.match(/^[-]?(\$)$/)) searchTerm = 'money';
|
||||
else if (searchTerm.match(/^(<3)$/)) searchTerm = 'heart';
|
||||
}
|
||||
}
|
||||
|
||||
asyncSearch.search(searchTerm);
|
||||
}
|
||||
function activateCmd(prefix) {
|
||||
setCmd({ prefix });
|
||||
cmdPrefix = prefix;
|
||||
|
||||
const { roomList, matrixClient } = initMatrix;
|
||||
function getRooms(roomIds) {
|
||||
return roomIds.map((rId) => {
|
||||
const room = matrixClient.getRoom(rId);
|
||||
return {
|
||||
name: room.name,
|
||||
roomId: room.roomId,
|
||||
};
|
||||
});
|
||||
}
|
||||
const setupSearch = {
|
||||
'/': () => asyncSearch.setup(commands, { keys: ['name'], isContain: true }),
|
||||
'>*': () => asyncSearch.setup(getRooms([...roomList.spaces]), { keys: ['name'], limit: 20 }),
|
||||
'>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }),
|
||||
'>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { keys: ['name'], limit: 20 }),
|
||||
':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], limit: 20 }),
|
||||
'@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({
|
||||
name: member.name,
|
||||
userId: member.userId.slice(1),
|
||||
})), { keys: ['name', 'userId'], limit: 20 }),
|
||||
};
|
||||
setupSearch[prefix]?.();
|
||||
}
|
||||
function deactivateCmd() {
|
||||
setCmd(null);
|
||||
cmdOption = undefined;
|
||||
cmdPrefix = undefined;
|
||||
}
|
||||
function fireCmd(myCmd) {
|
||||
if (myCmd.prefix.match(/^>[*#@]$/)) {
|
||||
selectRoom(myCmd.result.roomId);
|
||||
viewEvent.emit('cmd_fired');
|
||||
}
|
||||
if (myCmd.prefix === '/') {
|
||||
myCmd.result.exe(roomId, myCmd.option);
|
||||
viewEvent.emit('cmd_fired');
|
||||
}
|
||||
if (myCmd.prefix === ':') {
|
||||
viewEvent.emit('cmd_fired', {
|
||||
replace: myCmd.result.unicode,
|
||||
});
|
||||
}
|
||||
if (myCmd.prefix === '@') {
|
||||
viewEvent.emit('cmd_fired', {
|
||||
replace: myCmd.result.name,
|
||||
});
|
||||
}
|
||||
deactivateCmd();
|
||||
}
|
||||
function executeCmd() {
|
||||
if (cmd.suggestions.length === 0) return;
|
||||
fireCmd({
|
||||
prefix: cmd.prefix,
|
||||
option: cmd.option,
|
||||
result: cmd.suggestions[0],
|
||||
});
|
||||
}
|
||||
|
||||
function listenKeyboard(event) {
|
||||
const { activeElement } = document;
|
||||
const lastCmdItem = document.activeElement.parentNode.lastElementChild;
|
||||
if (event.keyCode === 27) {
|
||||
if (activeElement.className !== 'cmd-item') return;
|
||||
viewEvent.emit('focus_msg_input');
|
||||
}
|
||||
if (event.keyCode === 9) {
|
||||
if (lastCmdItem.className !== 'cmd-item') return;
|
||||
if (lastCmdItem !== activeElement) return;
|
||||
if (event.shiftKey) return;
|
||||
viewEvent.emit('focus_msg_input');
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
viewEvent.on('cmd_activate', activateCmd);
|
||||
viewEvent.on('cmd_deactivate', deactivateCmd);
|
||||
return () => {
|
||||
deactivateCmd();
|
||||
viewEvent.removeListener('cmd_activate', activateCmd);
|
||||
viewEvent.removeListener('cmd_deactivate', deactivateCmd);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
|
||||
viewEvent.on('cmd_process', processCmd);
|
||||
viewEvent.on('cmd_exe', executeCmd);
|
||||
asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
|
||||
return () => {
|
||||
if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
|
||||
|
||||
viewEvent.removeListener('cmd_process', processCmd);
|
||||
viewEvent.removeListener('cmd_exe', executeCmd);
|
||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
|
||||
};
|
||||
}, [cmd]);
|
||||
|
||||
if (typeof cmd?.error === 'string') {
|
||||
return (
|
||||
<div className="cmd-bar">
|
||||
<div className="cmd-bar__info">
|
||||
<div className="cmd-bar__info-indicator--error" />
|
||||
</div>
|
||||
<div className="cmd-bar__content">
|
||||
<Text className="cmd-bar__content-error" variant="b2">{cmd.error}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cmd-bar">
|
||||
<div className="cmd-bar__info">
|
||||
{cmd === null && <CmdHelp />}
|
||||
{cmd !== null && typeof cmd.suggestions === 'undefined' && <div className="cmd-bar__info-indicator" /> }
|
||||
{cmd !== null && typeof cmd.suggestions !== 'undefined' && <Text variant="b3">TAB</Text>}
|
||||
</div>
|
||||
<div className="cmd-bar__content">
|
||||
{cmd === null && (
|
||||
<FollowingMembers
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
viewEvent={viewEvent}
|
||||
/>
|
||||
)}
|
||||
{cmd !== null && typeof cmd.suggestions === 'undefined' && <Text className="cmd-bar__content-help" variant="b2">{getCmdActivationMessage(cmd.prefix)}</Text>}
|
||||
{cmd !== null && typeof cmd.suggestions !== 'undefined' && (
|
||||
<ScrollView horizontal vertical={false} invisible>
|
||||
<div className="cmd-bar__content__suggestions">{getCmdSuggestions(cmd, fireCmd)}</div>
|
||||
</ScrollView>
|
||||
)}
|
||||
</div>
|
||||
<div className="cmd-bar__more">
|
||||
{cmd !== null && cmd.prefix === '/' && <ViewCmd />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RoomViewCmdBar.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewCmdBar;
|
||||
144
src/app/organisms/room/RoomViewCmdBar.scss
Normal file
144
src/app/organisms/room/RoomViewCmdBar.scss
Normal file
@@ -0,0 +1,144 @@
|
||||
.overflow-ellipsis {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cmd-bar {
|
||||
--cmd-bar-height: 28px;
|
||||
min-height: var(--cmd-bar-height);
|
||||
display: flex;
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
width: calc(2 * var(--sp-extra-loose));
|
||||
padding-left: var(--sp-ultra-tight);
|
||||
[dir=rtl] & {
|
||||
padding-left: 0;
|
||||
padding-right: var(--sp-ultra-tight);
|
||||
}
|
||||
|
||||
& > * {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
& .ic-btn-surface {
|
||||
padding: 0;
|
||||
& .ic-raw {
|
||||
background-color: var(--tc-surface-low);
|
||||
}
|
||||
}
|
||||
& .context-menu .text-b2 {
|
||||
margin: var(--sp-extra-tight) var(--sp-tight);
|
||||
}
|
||||
|
||||
&-indicator,
|
||||
&-indicator--error {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-positive);
|
||||
}
|
||||
&-indicator--error {
|
||||
background-color: var(--bg-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
&-help,
|
||||
&-error {
|
||||
@extend .overflow-ellipsis;
|
||||
align-self: center;
|
||||
span {
|
||||
color: var(--tc-surface-low);
|
||||
&:first-child {
|
||||
color: var(--tc-surface-normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
&-error {
|
||||
color: var(--bg-danger);
|
||||
}
|
||||
&__suggestions {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
&__more {
|
||||
display: flex;
|
||||
& button {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0 var(--sp-normal);
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
box-shadow: none;
|
||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
||||
& .text {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
& .setting-tile {
|
||||
margin: var(--sp-tight);
|
||||
}
|
||||
}
|
||||
|
||||
& .timeline-change {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
padding: var(--sp-ultra-tight) var(--sp-normal);
|
||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
||||
|
||||
&__content {
|
||||
margin: 0;
|
||||
flex: unset;
|
||||
& > .text {
|
||||
@extend .overflow-ellipsis;
|
||||
& b {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cmd-item {
|
||||
--cmd-item-bar: inset 0 -2px 0 0 var(--bg-caution);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: var(--sp-extra-tight);
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
height: 100%;
|
||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
||||
cursor: pointer;
|
||||
|
||||
& .emoji {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: var(--sp-ultra-tight);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-caution-hover);
|
||||
}
|
||||
&:focus {
|
||||
background-color: var(--bg-caution-active);
|
||||
box-shadow: var(--cmd-item-bar);
|
||||
border-bottom: 2px solid transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[dir=rtl] & {
|
||||
margin-right: 0;
|
||||
margin-left: var(--sp-extra-tight);
|
||||
& .emoji {
|
||||
margin-right: 0;
|
||||
margin-left: var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
}
|
||||
581
src/app/organisms/room/RoomViewContent.jsx
Normal file
581
src/app/organisms/room/RoomViewContent.jsx
Normal file
@@ -0,0 +1,581 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect, useLayoutEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewContent.scss';
|
||||
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
||||
import { getUsername, getUsernameOfRoomMember, doesRoomHaveUnread } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { diffMinutes, isNotInSameDay } from '../../../util/common';
|
||||
import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigation';
|
||||
|
||||
import Divider from '../../atoms/divider/Divider';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
|
||||
import {
|
||||
Message,
|
||||
MessageHeader,
|
||||
MessageReply,
|
||||
MessageContent,
|
||||
MessageEdit,
|
||||
MessageReactionGroup,
|
||||
MessageReaction,
|
||||
MessageOptions,
|
||||
PlaceholderMessage,
|
||||
} from '../../molecules/message/Message';
|
||||
import * as Media from '../../molecules/media/Media';
|
||||
import RoomIntro from '../../molecules/room-intro/RoomIntro';
|
||||
import TimelineChange from '../../molecules/message/TimelineChange';
|
||||
|
||||
import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
|
||||
import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
|
||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
||||
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
|
||||
import { parseReply, parseTimelineChange } from './common';
|
||||
|
||||
const MAX_MSG_DIFF_MINUTES = 5;
|
||||
|
||||
function genPlaceholders() {
|
||||
return (
|
||||
<>
|
||||
<PlaceholderMessage key="placeholder-1" />
|
||||
<PlaceholderMessage key="placeholder-2" />
|
||||
<PlaceholderMessage key="placeholder-3" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isMedia(mE) {
|
||||
return (
|
||||
mE.getContent()?.msgtype === 'm.file'
|
||||
|| mE.getContent()?.msgtype === 'm.image'
|
||||
|| mE.getContent()?.msgtype === 'm.audio'
|
||||
|| mE.getContent()?.msgtype === 'm.video'
|
||||
|| mE.getType() === 'm.sticker'
|
||||
);
|
||||
}
|
||||
|
||||
function genMediaContent(mE) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mContent = mE.getContent();
|
||||
if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
||||
|
||||
let mediaMXC = mContent?.url;
|
||||
const isEncryptedFile = typeof mediaMXC === 'undefined';
|
||||
if (isEncryptedFile) mediaMXC = mContent?.file?.url;
|
||||
|
||||
let thumbnailMXC = mContent?.info?.thumbnail_url;
|
||||
|
||||
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
||||
|
||||
let msgType = mE.getContent()?.msgtype;
|
||||
if (mE.getType() === 'm.sticker') msgType = 'm.image';
|
||||
|
||||
switch (msgType) {
|
||||
case 'm.file':
|
||||
return (
|
||||
<Media.File
|
||||
name={mContent.body}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
type={mContent.info?.mimetype}
|
||||
file={mContent.file || null}
|
||||
/>
|
||||
);
|
||||
case 'm.image':
|
||||
return (
|
||||
<Media.Image
|
||||
name={mContent.body}
|
||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
file={isEncryptedFile ? mContent.file : null}
|
||||
type={mContent.info?.mimetype}
|
||||
/>
|
||||
);
|
||||
case 'm.audio':
|
||||
return (
|
||||
<Media.Audio
|
||||
name={mContent.body}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
type={mContent.info?.mimetype}
|
||||
file={mContent.file || null}
|
||||
/>
|
||||
);
|
||||
case 'm.video':
|
||||
if (typeof thumbnailMXC === 'undefined') {
|
||||
thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
|
||||
}
|
||||
return (
|
||||
<Media.Video
|
||||
name={mContent.body}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
|
||||
thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
|
||||
thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
|
||||
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
||||
file={isEncryptedFile ? mContent.file : null}
|
||||
type={mContent.info?.mimetype}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function genRoomIntro(mEvent, roomTimeline) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
const isDM = initMatrix.roomList.directs.has(roomTimeline.roomId);
|
||||
let avatarSrc = roomTimeline.room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
|
||||
avatarSrc = isDM ? roomTimeline.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
|
||||
return (
|
||||
<RoomIntro
|
||||
key={mEvent ? mEvent.getId() : 'room-intro'}
|
||||
roomId={roomTimeline.roomId}
|
||||
avatarSrc={avatarSrc}
|
||||
name={roomTimeline.room.name}
|
||||
heading={`Welcome to ${roomTimeline.room.name}`}
|
||||
desc={`This is the beginning of ${roomTimeline.room.name} room.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
|
||||
time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getMyEmojiEventId(emojiKey, eventId, roomTimeline) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const rEvents = roomTimeline.reactionTimeline.get(eventId);
|
||||
let rEventId = null;
|
||||
rEvents?.find((rE) => {
|
||||
if (rE.getRelation() === null) return false;
|
||||
if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
|
||||
rEventId = rE.getId();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return rEventId;
|
||||
}
|
||||
|
||||
function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
|
||||
const myAlreadyReactEventId = getMyEmojiEventId(emojiKey, eventId, roomTimeline);
|
||||
if (typeof myAlreadyReactEventId === 'string') {
|
||||
if (myAlreadyReactEventId.indexOf('~') === 0) return;
|
||||
redactEvent(roomId, myAlreadyReactEventId);
|
||||
return;
|
||||
}
|
||||
sendReaction(roomId, eventId, emojiKey);
|
||||
}
|
||||
|
||||
function pickEmoji(e, roomId, eventId, roomTimeline) {
|
||||
const boxInfo = e.target.getBoundingClientRect();
|
||||
openEmojiBoard({
|
||||
x: boxInfo.x,
|
||||
y: boxInfo.y,
|
||||
detail: e.detail,
|
||||
}, (emoji) => {
|
||||
toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
|
||||
e.target.click();
|
||||
});
|
||||
}
|
||||
|
||||
let wasAtBottom = true;
|
||||
function RoomViewContent({
|
||||
roomId, roomTimeline, timelineScroll, viewEvent,
|
||||
}) {
|
||||
const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
|
||||
const [onStateUpdate, updateState] = useState(null);
|
||||
const [onPagination, setOnPagination] = useState(null);
|
||||
const [editEvent, setEditEvent] = useState(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
function autoLoadTimeline() {
|
||||
if (timelineScroll.isScrollable() === true) return;
|
||||
roomTimeline.paginateBack();
|
||||
}
|
||||
function trySendingReadReceipt() {
|
||||
const { room, timeline } = roomTimeline;
|
||||
if (doesRoomHaveUnread(room) && timeline.length !== 0) {
|
||||
mx.sendReadReceipt(timeline[timeline.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
function onReachedTop() {
|
||||
if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
|
||||
roomTimeline.paginateBack();
|
||||
}
|
||||
function toggleOnReachedBottom(isBottom) {
|
||||
wasAtBottom = isBottom;
|
||||
if (!isBottom) return;
|
||||
trySendingReadReceipt();
|
||||
}
|
||||
|
||||
const updatePAG = (canPagMore) => {
|
||||
if (!canPagMore) {
|
||||
setIsReachedTimelineEnd(true);
|
||||
} else {
|
||||
setOnPagination({});
|
||||
autoLoadTimeline();
|
||||
}
|
||||
};
|
||||
// force update RoomTimeline on cons.events.roomTimeline.EVENT
|
||||
const updateRT = () => {
|
||||
if (wasAtBottom) {
|
||||
trySendingReadReceipt();
|
||||
}
|
||||
updateState({});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsReachedTimelineEnd(false);
|
||||
wasAtBottom = true;
|
||||
}, [roomId]);
|
||||
useEffect(() => trySendingReadReceipt(), [roomTimeline]);
|
||||
|
||||
// init room setup completed.
|
||||
// listen for future. setup stateUpdate listener.
|
||||
useEffect(() => {
|
||||
roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
|
||||
viewEvent.on('reached-top', onReachedTop);
|
||||
viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom);
|
||||
|
||||
return () => {
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
|
||||
viewEvent.removeListener('reached-top', onReachedTop);
|
||||
viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom);
|
||||
};
|
||||
}, [roomTimeline, isReachedTimelineEnd, onPagination]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
timelineScroll.reachBottom();
|
||||
autoLoadTimeline();
|
||||
}, [roomTimeline]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (onPagination === null) return;
|
||||
timelineScroll.tryRestoringScroll();
|
||||
}, [onPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onStateUpdate === null) return;
|
||||
if (wasAtBottom) timelineScroll.reachBottom();
|
||||
}, [onStateUpdate]);
|
||||
|
||||
let prevMEvent = null;
|
||||
function genMessage(mEvent) {
|
||||
const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel;
|
||||
const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
|
||||
|
||||
const isContentOnly = (
|
||||
prevMEvent !== null
|
||||
&& prevMEvent.getType() !== 'm.room.member'
|
||||
&& diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
|
||||
&& prevMEvent.getSender() === mEvent.getSender()
|
||||
);
|
||||
|
||||
let content = mEvent.getContent().body;
|
||||
if (typeof content === 'undefined') return null;
|
||||
let reply = null;
|
||||
let reactions = null;
|
||||
let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html';
|
||||
const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
|
||||
const isEdited = roomTimeline.editedTimeline.has(mEvent.getId());
|
||||
const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId());
|
||||
|
||||
if (isReply) {
|
||||
const parsedContent = parseReply(content);
|
||||
if (parsedContent !== null) {
|
||||
const c = roomTimeline.room.currentState;
|
||||
const displayNameToUserIds = c.getUserIdsWithDisplayName(parsedContent.displayName);
|
||||
const ID = parsedContent.userId || displayNameToUserIds[0];
|
||||
reply = {
|
||||
color: colorMXID(ID || parsedContent.displayName),
|
||||
to: parsedContent.displayName || getUsername(parsedContent.userId),
|
||||
content: parsedContent.replyContent,
|
||||
};
|
||||
content = parsedContent.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdited) {
|
||||
const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
|
||||
const latestEdited = editedList[editedList.length - 1];
|
||||
if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null;
|
||||
const latestEditBody = latestEdited.getContent()['m.new_content'].body;
|
||||
const parsedEditedContent = parseReply(latestEditBody);
|
||||
isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html';
|
||||
if (parsedEditedContent === null) {
|
||||
content = latestEditBody;
|
||||
} else {
|
||||
content = parsedEditedContent.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (haveReactions) {
|
||||
reactions = [];
|
||||
roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => {
|
||||
if (rEvent.getRelation() === null) return;
|
||||
function alreadyHaveThisReaction(rE) {
|
||||
for (let i = 0; i < reactions.length; i += 1) {
|
||||
if (reactions[i].key === rE.getRelation().key) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (alreadyHaveThisReaction(rEvent)) {
|
||||
for (let i = 0; i < reactions.length; i += 1) {
|
||||
if (reactions[i].key === rEvent.getRelation().key) {
|
||||
reactions[i].users.push(rEvent.getSender());
|
||||
if (reactions[i].isActive !== true) {
|
||||
const myUserId = initMatrix.matrixClient.getUserId();
|
||||
reactions[i].isActive = rEvent.getSender() === myUserId;
|
||||
if (reactions[i].isActive) reactions[i].id = rEvent.getId();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reactions.push({
|
||||
id: rEvent.getId(),
|
||||
key: rEvent.getRelation().key,
|
||||
users: [rEvent.getSender()],
|
||||
isActive: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const senderMXIDColor = colorMXID(mEvent.sender.userId);
|
||||
const userAvatar = isContentOnly ? null : (
|
||||
<Avatar
|
||||
imageSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
|
||||
text={getUsernameOfRoomMember(mEvent.sender).slice(0, 1)}
|
||||
bgColor={senderMXIDColor}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
const userHeader = isContentOnly ? null : (
|
||||
<MessageHeader
|
||||
userId={mEvent.sender.userId}
|
||||
name={getUsernameOfRoomMember(mEvent.sender)}
|
||||
color={senderMXIDColor}
|
||||
time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
|
||||
/>
|
||||
);
|
||||
const userReply = reply === null ? null : (
|
||||
<MessageReply
|
||||
name={reply.to}
|
||||
color={reply.color}
|
||||
content={reply.content}
|
||||
/>
|
||||
);
|
||||
const userContent = (
|
||||
<MessageContent
|
||||
isMarkdown={isMarkdown}
|
||||
content={isMedia(mEvent) ? genMediaContent(mEvent) : content}
|
||||
isEdited={isEdited}
|
||||
/>
|
||||
);
|
||||
const userReactions = reactions === null ? null : (
|
||||
<MessageReactionGroup>
|
||||
{
|
||||
reactions.map((reaction) => (
|
||||
<MessageReaction
|
||||
key={reaction.id}
|
||||
reaction={reaction.key}
|
||||
users={reaction.users}
|
||||
isActive={reaction.isActive}
|
||||
onClick={() => {
|
||||
toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<IconButton
|
||||
onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
|
||||
src={EmojiAddIC}
|
||||
size="extra-small"
|
||||
tooltip="Add reaction"
|
||||
/>
|
||||
</MessageReactionGroup>
|
||||
);
|
||||
const userOptions = (
|
||||
<MessageOptions>
|
||||
<IconButton
|
||||
onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
|
||||
src={EmojiAddIC}
|
||||
size="extra-small"
|
||||
tooltip="Add reaction"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
|
||||
}}
|
||||
src={ReplyArrowIC}
|
||||
size="extra-small"
|
||||
tooltip="Reply"
|
||||
/>
|
||||
{(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
|
||||
<IconButton
|
||||
onClick={() => setEditEvent(mEvent)}
|
||||
src={PencilIC}
|
||||
size="extra-small"
|
||||
tooltip="Edit"
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
content={() => (
|
||||
<>
|
||||
<MenuHeader>Options</MenuHeader>
|
||||
<MenuItem
|
||||
iconSrc={EmojiAddIC}
|
||||
onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
|
||||
>
|
||||
Add reaction
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
iconSrc={ReplyArrowIC}
|
||||
onClick={() => {
|
||||
viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
|
||||
}}
|
||||
>
|
||||
Reply
|
||||
</MenuItem>
|
||||
{(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && (
|
||||
<MenuItem iconSrc={PencilIC} onClick={() => setEditEvent(mEvent)}>Edit</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
iconSrc={TickMarkIC}
|
||||
onClick={() => openReadReceipts(roomId, mEvent.getId())}
|
||||
>
|
||||
Read receipts
|
||||
</MenuItem>
|
||||
{(canIRedact || mEvent.getSender() === mx.getUserId()) && (
|
||||
<>
|
||||
<MenuBorder />
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
iconSrc={BinIC}
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to delete this event')) {
|
||||
redactEvent(roomId, mEvent.getId());
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
render={(toggleMenu) => (
|
||||
<IconButton
|
||||
onClick={toggleMenu}
|
||||
src={VerticalMenuIC}
|
||||
size="extra-small"
|
||||
tooltip="Options"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</MessageOptions>
|
||||
);
|
||||
|
||||
const isEditingEvent = editEvent?.getId() === mEvent.getId();
|
||||
const myMessageEl = (
|
||||
<Message
|
||||
key={mEvent.getId()}
|
||||
avatar={userAvatar}
|
||||
header={userHeader}
|
||||
reply={userReply}
|
||||
content={editEvent !== null && isEditingEvent ? null : userContent}
|
||||
editContent={editEvent !== null && isEditingEvent ? (
|
||||
<MessageEdit
|
||||
content={content}
|
||||
onSave={(newBody) => {
|
||||
if (newBody !== content) {
|
||||
initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
|
||||
}
|
||||
setEditEvent(null);
|
||||
}}
|
||||
onCancel={() => setEditEvent(null)}
|
||||
/>
|
||||
) : null}
|
||||
reactions={userReactions}
|
||||
options={editEvent !== null && isEditingEvent ? null : userOptions}
|
||||
/>
|
||||
);
|
||||
return myMessageEl;
|
||||
}
|
||||
|
||||
function renderMessage(mEvent) {
|
||||
if (mEvent.getType() === 'm.room.create') return genRoomIntro(mEvent, roomTimeline);
|
||||
if (
|
||||
mEvent.getType() !== 'm.room.message'
|
||||
&& mEvent.getType() !== 'm.room.encrypted'
|
||||
&& mEvent.getType() !== 'm.room.member'
|
||||
&& mEvent.getType() !== 'm.sticker'
|
||||
) return false;
|
||||
if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
|
||||
|
||||
// ignore if message is deleted
|
||||
if (mEvent.isRedacted()) return false;
|
||||
|
||||
let divider = null;
|
||||
if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
|
||||
divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
|
||||
}
|
||||
|
||||
if (mEvent.getType() !== 'm.room.member') {
|
||||
const messageComp = genMessage(mEvent);
|
||||
prevMEvent = mEvent;
|
||||
return (
|
||||
<React.Fragment key={`box-${mEvent.getId()}`}>
|
||||
{divider}
|
||||
{messageComp}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
prevMEvent = mEvent;
|
||||
const timelineChange = parseTimelineChange(mEvent);
|
||||
if (timelineChange === null) return null;
|
||||
return (
|
||||
<React.Fragment key={`box-${mEvent.getId()}`}>
|
||||
{divider}
|
||||
<TimelineChange
|
||||
key={mEvent.getId()}
|
||||
variant={timelineChange.variant}
|
||||
content={timelineChange.content}
|
||||
time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="room-view__content">
|
||||
<div className="timeline__wrapper">
|
||||
{ roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() }
|
||||
{ roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genRoomIntro(undefined, roomTimeline)}
|
||||
{ roomTimeline.timeline.map(renderMessage) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RoomViewContent.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
timelineScroll: PropTypes.shape({}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewContent;
|
||||
13
src/app/organisms/room/RoomViewContent.scss
Normal file
13
src/app/organisms/room/RoomViewContent.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.room-view__content {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
& .timeline__wrapper {
|
||||
--typing-noti-height: 28px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding-bottom: var(--typing-noti-height);
|
||||
}
|
||||
}
|
||||
83
src/app/organisms/room/RoomViewFloating.jsx
Normal file
83
src/app/organisms/room/RoomViewFloating.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewFloating.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
|
||||
import { getUsersActionJsx } from './common';
|
||||
|
||||
function RoomViewFloating({
|
||||
roomId, roomTimeline, timelineScroll, viewEvent,
|
||||
}) {
|
||||
const [reachedBottom, setReachedBottom] = useState(true);
|
||||
const [typingMembers, setTypingMembers] = useState(new Set());
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
function isSomeoneTyping(members) {
|
||||
const m = members;
|
||||
m.delete(mx.getUserId());
|
||||
if (m.size === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTypingMessage(members) {
|
||||
const userIds = members;
|
||||
userIds.delete(mx.getUserId());
|
||||
return getUsersActionJsx(roomId, [...userIds], 'typing...');
|
||||
}
|
||||
|
||||
function updateTyping(members) {
|
||||
setTypingMembers(members);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setReachedBottom(true);
|
||||
setTypingMembers(new Set());
|
||||
viewEvent.on('toggle-reached-bottom', setReachedBottom);
|
||||
return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
||||
return () => {
|
||||
roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`room-view__typing${isSomeoneTyping(typingMembers) ? ' room-view__typing--open' : ''}`}>
|
||||
<div className="bouncingLoader"><div /></div>
|
||||
<Text variant="b2">{getTypingMessage(typingMembers)}</Text>
|
||||
</div>
|
||||
<div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
timelineScroll.enableSmoothScroll();
|
||||
timelineScroll.reachBottom();
|
||||
timelineScroll.disableSmoothScroll();
|
||||
}}
|
||||
src={ChevronBottomIC}
|
||||
tooltip="Scroll to Bottom"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
RoomViewFloating.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
timelineScroll: PropTypes.shape({
|
||||
reachBottom: PropTypes.func,
|
||||
}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewFloating;
|
||||
84
src/app/organisms/room/RoomViewFloating.scss
Normal file
84
src/app/organisms/room/RoomViewFloating.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
.room-view {
|
||||
&__typing {
|
||||
display: flex;
|
||||
padding: var(--sp-ultra-tight) var(--sp-normal);
|
||||
background: var(--bg-surface);
|
||||
transition: transform 200ms ease-in-out;
|
||||
|
||||
& b {
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
|
||||
&--open {
|
||||
transform: translateY(-99%);
|
||||
}
|
||||
|
||||
& .text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
}
|
||||
|
||||
.bouncingLoader {
|
||||
transform: translateY(2px);
|
||||
margin: 0 calc(var(--sp-ultra-tight) / 2);
|
||||
}
|
||||
.bouncingLoader > div,
|
||||
.bouncingLoader:before,
|
||||
.bouncingLoader:after {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--tc-surface-high);
|
||||
border-radius: 50%;
|
||||
animation: bouncing-loader 0.6s infinite alternate;
|
||||
}
|
||||
|
||||
.bouncingLoader:before,
|
||||
.bouncingLoader:after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bouncingLoader > div {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.bouncingLoader > div {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.bouncingLoader:after {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bouncing-loader {
|
||||
to {
|
||||
opacity: 0.1;
|
||||
transform: translate3d(0, -4px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
&__STB {
|
||||
position: absolute;
|
||||
right: var(--sp-normal);
|
||||
bottom: 0;
|
||||
border-radius: var(--bo-radius);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
background-color: var(--bg-surface-low);
|
||||
transition: transform 200ms ease-in-out;
|
||||
transform: translateY(100%) scale(0);
|
||||
[dir=rtl] & {
|
||||
right: unset;
|
||||
left: var(--sp-normal);
|
||||
}
|
||||
|
||||
&--open {
|
||||
transform: translateY(-28px) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/app/organisms/room/RoomViewHeader.jsx
Normal file
62
src/app/organisms/room/RoomViewHeader.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||
|
||||
function RoomViewHeader({ roomId }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const isDM = initMatrix.roomList.directs.has(roomId);
|
||||
let avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
|
||||
avatarSrc = isDM ? mx.getRoom(roomId).getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 36, 36, 'crop') : avatarSrc;
|
||||
const roomName = mx.getRoom(roomId).name;
|
||||
const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomId)} size="small" />
|
||||
<TitleWrapper>
|
||||
<Text variant="h2">{roomName}</Text>
|
||||
{ typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
|
||||
</TitleWrapper>
|
||||
<IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
|
||||
<ContextMenu
|
||||
placement="bottom"
|
||||
content={(toogleMenu) => (
|
||||
<>
|
||||
<MenuHeader>Options</MenuHeader>
|
||||
{/* <MenuBorder /> */}
|
||||
<MenuItem
|
||||
iconSrc={AddUserIC}
|
||||
onClick={() => {
|
||||
openInviteUser(roomId); toogleMenu();
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</MenuItem>
|
||||
<MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId)}>Leave</MenuItem>
|
||||
</>
|
||||
)}
|
||||
render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
|
||||
/>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
RoomViewHeader.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewHeader;
|
||||
413
src/app/organisms/room/RoomViewInput.jsx
Normal file
413
src/app/organisms/room/RoomViewInput.jsx
Normal file
@@ -0,0 +1,413 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewInput.scss';
|
||||
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import settings from '../../../client/state/settings';
|
||||
import { openEmojiBoard } from '../../../client/action/navigation';
|
||||
import { bytesToSize } from '../../../util/common';
|
||||
import { getUsername } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import { MessageReply } from '../../molecules/message/Message';
|
||||
|
||||
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||
import SendIC from '../../../../public/res/ic/outlined/send.svg';
|
||||
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
|
||||
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
|
||||
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
|
||||
import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg';
|
||||
import FileIC from '../../../../public/res/ic/outlined/file.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
const CMD_REGEX = /(\/|>[#*@]|:|@)(\S*)$/;
|
||||
let isTyping = false;
|
||||
let isCmdActivated = false;
|
||||
let cmdCursorPos = null;
|
||||
function RoomViewInput({
|
||||
roomId, roomTimeline, timelineScroll, viewEvent,
|
||||
}) {
|
||||
const [attachment, setAttachment] = useState(null);
|
||||
const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
|
||||
const textAreaRef = useRef(null);
|
||||
const inputBaseRef = useRef(null);
|
||||
const uploadInputRef = useRef(null);
|
||||
const uploadProgressRef = useRef(null);
|
||||
const rightOptionsRef = useRef(null);
|
||||
const escBtnRef = useRef(null);
|
||||
|
||||
const TYPING_TIMEOUT = 5000;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomsInput } = initMatrix;
|
||||
|
||||
function requestFocusInput() {
|
||||
if (textAreaRef === null) return;
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
|
||||
viewEvent.on('focus_msg_input', requestFocusInput);
|
||||
return () => {
|
||||
settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
|
||||
viewEvent.removeListener('focus_msg_input', requestFocusInput);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendIsTyping = (isT) => {
|
||||
mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
|
||||
isTyping = isT;
|
||||
|
||||
if (isT === true) {
|
||||
setTimeout(() => {
|
||||
if (isTyping) sendIsTyping(false);
|
||||
}, TYPING_TIMEOUT);
|
||||
}
|
||||
};
|
||||
|
||||
function uploadingProgress(myRoomId, { loaded, total }) {
|
||||
if (myRoomId !== roomId) return;
|
||||
const progressPer = Math.round((loaded * 100) / total);
|
||||
uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
|
||||
inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
|
||||
}
|
||||
function clearAttachment(myRoomId) {
|
||||
if (roomId !== myRoomId) return;
|
||||
setAttachment(null);
|
||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
||||
uploadInputRef.current.value = null;
|
||||
}
|
||||
|
||||
function rightOptionsA11Y(A11Y) {
|
||||
const rightOptions = rightOptionsRef.current.children;
|
||||
for (let index = 0; index < rightOptions.length; index += 1) {
|
||||
rightOptions[index].disabled = !A11Y;
|
||||
}
|
||||
}
|
||||
|
||||
function activateCmd(prefix) {
|
||||
isCmdActivated = true;
|
||||
requestAnimationFrame(() => {
|
||||
inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-positive)';
|
||||
escBtnRef.current.style.display = 'block';
|
||||
});
|
||||
rightOptionsA11Y(false);
|
||||
viewEvent.emit('cmd_activate', prefix);
|
||||
}
|
||||
function deactivateCmd() {
|
||||
if (inputBaseRef.current !== null) {
|
||||
requestAnimationFrame(() => {
|
||||
inputBaseRef.current.style.boxShadow = 'var(--bs-surface-border)';
|
||||
escBtnRef.current.style.display = 'none';
|
||||
});
|
||||
rightOptionsA11Y(true);
|
||||
}
|
||||
isCmdActivated = false;
|
||||
cmdCursorPos = null;
|
||||
}
|
||||
function deactivateCmdAndEmit() {
|
||||
deactivateCmd();
|
||||
viewEvent.emit('cmd_deactivate');
|
||||
}
|
||||
function errorCmd() {
|
||||
requestAnimationFrame(() => {
|
||||
inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-danger)';
|
||||
});
|
||||
}
|
||||
function setCursorPosition(pos) {
|
||||
setTimeout(() => {
|
||||
textAreaRef.current.focus();
|
||||
textAreaRef.current.setSelectionRange(pos, pos);
|
||||
}, 0);
|
||||
}
|
||||
function replaceCmdWith(msg, cursor, replacement) {
|
||||
if (msg === null) return null;
|
||||
const targetInput = msg.slice(0, cursor);
|
||||
const cmdParts = targetInput.match(CMD_REGEX);
|
||||
const leadingInput = msg.slice(0, cmdParts.index);
|
||||
if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
|
||||
return leadingInput + replacement + msg.slice(cursor);
|
||||
}
|
||||
function firedCmd(cmdData) {
|
||||
const msg = textAreaRef.current.value;
|
||||
textAreaRef.current.value = replaceCmdWith(
|
||||
msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
|
||||
);
|
||||
deactivateCmd();
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
if (settings.isTouchScreenDevice) return;
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
|
||||
function setUpReply(userId, eventId, content) {
|
||||
setReplyTo({ userId, eventId, content });
|
||||
roomsInput.setReplyTo(roomId, { userId, eventId, content });
|
||||
focusInput();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
||||
roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
||||
viewEvent.on('cmd_error', errorCmd);
|
||||
viewEvent.on('cmd_fired', firedCmd);
|
||||
viewEvent.on('reply_to', setUpReply);
|
||||
if (textAreaRef?.current !== null) {
|
||||
isTyping = false;
|
||||
focusInput();
|
||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
||||
setAttachment(roomsInput.getAttachment(roomId));
|
||||
setReplyTo(roomsInput.getReplyTo(roomId));
|
||||
}
|
||||
return () => {
|
||||
roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
|
||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
|
||||
roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
|
||||
viewEvent.removeListener('cmd_error', errorCmd);
|
||||
viewEvent.removeListener('cmd_fired', firedCmd);
|
||||
viewEvent.removeListener('reply_to', setUpReply);
|
||||
if (isCmdActivated) deactivateCmd();
|
||||
if (textAreaRef?.current === null) return;
|
||||
|
||||
const msg = textAreaRef.current.value;
|
||||
inputBaseRef.current.style.backgroundImage = 'unset';
|
||||
if (msg.trim() === '') {
|
||||
roomsInput.setMessage(roomId, '');
|
||||
return;
|
||||
}
|
||||
roomsInput.setMessage(roomId, msg);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
async function sendMessage() {
|
||||
const msgBody = textAreaRef.current.value;
|
||||
if (roomsInput.isSending(roomId)) return;
|
||||
if (msgBody.trim() === '' && attachment === null) return;
|
||||
sendIsTyping(false);
|
||||
|
||||
roomsInput.setMessage(roomId, msgBody);
|
||||
if (attachment !== null) {
|
||||
roomsInput.setAttachment(roomId, attachment);
|
||||
}
|
||||
textAreaRef.current.disabled = true;
|
||||
textAreaRef.current.style.cursor = 'not-allowed';
|
||||
await roomsInput.sendInput(roomId);
|
||||
textAreaRef.current.disabled = false;
|
||||
textAreaRef.current.style.cursor = 'unset';
|
||||
focusInput();
|
||||
|
||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
||||
timelineScroll.reachBottom();
|
||||
viewEvent.emit('message_sent');
|
||||
textAreaRef.current.style.height = 'unset';
|
||||
if (replyTo !== null) setReplyTo(null);
|
||||
}
|
||||
|
||||
function processTyping(msg) {
|
||||
const isEmptyMsg = msg === '';
|
||||
|
||||
if (isEmptyMsg && isTyping) {
|
||||
sendIsTyping(false);
|
||||
return;
|
||||
}
|
||||
if (!isEmptyMsg && !isTyping) {
|
||||
sendIsTyping(true);
|
||||
}
|
||||
}
|
||||
|
||||
function getCursorPosition() {
|
||||
return textAreaRef.current.selectionStart;
|
||||
}
|
||||
|
||||
function recognizeCmd(rawInput) {
|
||||
const cursor = getCursorPosition();
|
||||
const targetInput = rawInput.slice(0, cursor);
|
||||
|
||||
const cmdParts = targetInput.match(CMD_REGEX);
|
||||
if (cmdParts === null) {
|
||||
if (isCmdActivated) deactivateCmdAndEmit();
|
||||
return;
|
||||
}
|
||||
const cmdPrefix = cmdParts[1];
|
||||
const cmdSlug = cmdParts[2];
|
||||
|
||||
if (cmdPrefix === ':') {
|
||||
// skip emoji autofill command if link is suspected.
|
||||
const checkForLink = targetInput.slice(0, cmdParts.index);
|
||||
if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
|
||||
deactivateCmdAndEmit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cmdCursorPos = cursor;
|
||||
if (cmdSlug === '') {
|
||||
activateCmd(cmdPrefix);
|
||||
return;
|
||||
}
|
||||
if (!isCmdActivated) activateCmd(cmdPrefix);
|
||||
requestAnimationFrame(() => {
|
||||
inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-caution)';
|
||||
});
|
||||
viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
|
||||
}
|
||||
|
||||
function handleMsgTyping(e) {
|
||||
const msg = e.target.value;
|
||||
recognizeCmd(e.target.value);
|
||||
if (!isCmdActivated) processTyping(msg);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.keyCode === 13 && e.shiftKey === false) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isCmdActivated) {
|
||||
viewEvent.emit('cmd_exe');
|
||||
} else sendMessage();
|
||||
}
|
||||
if (e.keyCode === 27 && isCmdActivated) {
|
||||
deactivateCmdAndEmit();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function addEmoji(emoji) {
|
||||
textAreaRef.current.value += emoji.unicode;
|
||||
}
|
||||
|
||||
function handleUploadClick() {
|
||||
if (attachment === null) uploadInputRef.current.click();
|
||||
else {
|
||||
roomsInput.cancelAttachment(roomId);
|
||||
}
|
||||
}
|
||||
function uploadFileChange(e) {
|
||||
const file = e.target.files.item(0);
|
||||
setAttachment(file);
|
||||
if (file !== null) roomsInput.setAttachment(roomId, file);
|
||||
}
|
||||
|
||||
function renderInputs() {
|
||||
return (
|
||||
<>
|
||||
<div className={`room-input__option-container${attachment === null ? '' : ' room-attachment__option'}`}>
|
||||
<input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
|
||||
<IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
|
||||
</div>
|
||||
<div ref={inputBaseRef} className="room-input__input-container">
|
||||
{roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
|
||||
<ScrollView autoHide>
|
||||
<Text className="room-input__textarea-wrapper">
|
||||
<TextareaAutosize
|
||||
ref={textAreaRef}
|
||||
onChange={handleMsgTyping}
|
||||
onResize={() => timelineScroll.autoReachBottom()}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Send a message..."
|
||||
/>
|
||||
</Text>
|
||||
</ScrollView>
|
||||
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
|
||||
<button ref={escBtnRef} tabIndex="-1" onClick={deactivateCmdAndEmit} className="btn-cmd-esc" type="button"><Text variant="b3">ESC</Text></button>
|
||||
</div>
|
||||
<div ref={rightOptionsRef} className="room-input__option-container">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
const boxInfo = e.target.getBoundingClientRect();
|
||||
openEmojiBoard({
|
||||
x: boxInfo.x + (document.dir === 'rtl' ? -80 : 80),
|
||||
y: boxInfo.y - 250,
|
||||
detail: e.detail,
|
||||
}, addEmoji);
|
||||
}}
|
||||
tooltip="Emoji"
|
||||
src={EmojiIC}
|
||||
/>
|
||||
<IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function attachFile() {
|
||||
const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
|
||||
return (
|
||||
<div className="room-attachment">
|
||||
<div className={`room-attachment__preview${fileType !== 'image' ? ' room-attachment__icon' : ''}`}>
|
||||
{fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
|
||||
{fileType === 'video' && <RawIcon src={VLCIC} />}
|
||||
{fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
|
||||
{fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
|
||||
</div>
|
||||
<div className="room-attachment__info">
|
||||
<Text variant="b1">{attachment.name}</Text>
|
||||
<Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function attachReply() {
|
||||
return (
|
||||
<div className="room-reply">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
roomsInput.cancelReplyTo(roomId);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
src={CrossIC}
|
||||
tooltip="Cancel reply"
|
||||
size="extra-small"
|
||||
/>
|
||||
<MessageReply
|
||||
userId={replyTo.userId}
|
||||
name={getUsername(replyTo.userId)}
|
||||
color={colorMXID(replyTo.userId)}
|
||||
content={replyTo.content}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ replyTo !== null && attachReply()}
|
||||
{ attachment !== null && attachFile() }
|
||||
<form className="room-input" onSubmit={(e) => { e.preventDefault(); }}>
|
||||
{
|
||||
roomTimeline.room.isSpaceRoom()
|
||||
? <Text className="room-input__space" variant="b1">Spaces are yet to be implemented</Text>
|
||||
: renderInputs()
|
||||
}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
RoomViewInput.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
timelineScroll: PropTypes.shape({
|
||||
reachBottom: PropTypes.func,
|
||||
autoReachBottom: PropTypes.func,
|
||||
tryRestoringScroll: PropTypes.func,
|
||||
enableSmoothScroll: PropTypes.func,
|
||||
disableSmoothScroll: PropTypes.func,
|
||||
}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomViewInput;
|
||||
128
src/app/organisms/room/RoomViewInput.scss
Normal file
128
src/app/organisms/room/RoomViewInput.scss
Normal file
@@ -0,0 +1,128 @@
|
||||
.room-input {
|
||||
padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
|
||||
&__space {
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
margin: auto;
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
&__input-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
margin: 0 calc(var(--sp-tight) - 2px);
|
||||
background-color: var(--bg-surface-low);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
border-radius: var(--bo-radius);
|
||||
|
||||
& > .ic-raw {
|
||||
transform: scale(0.8);
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
& .btn-cmd-esc {
|
||||
display: none;
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
cursor: pointer;
|
||||
& .text { color: var(--tc-surface-normal); }
|
||||
}
|
||||
|
||||
& .scrollbar {
|
||||
max-height: 50vh;
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
margin-left: var(--sp-tight);
|
||||
[dir=rtl] & {
|
||||
margin-left: 0;
|
||||
margin-right: var(--sp-tight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea-wrapper {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& textarea {
|
||||
resize: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: var(--sp-ultra-tight) 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-attachment {
|
||||
--side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: var(--side-spacing);
|
||||
margin-top: var(--sp-extra-tight);
|
||||
line-height: 0;
|
||||
[dir=rtl] & {
|
||||
margin-left: 0;
|
||||
margin-right: var(--side-spacing);
|
||||
}
|
||||
|
||||
&__preview > img {
|
||||
max-height: 40px;
|
||||
border-radius: var(--bo-radius);
|
||||
}
|
||||
&__icon {
|
||||
padding: var(--sp-extra-tight);
|
||||
background-color: var(--bg-surface-low);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
border-radius: var(--bo-radius);
|
||||
}
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
&__option button {
|
||||
transition: transform 200ms ease-in-out;
|
||||
transform: translateY(-48px);
|
||||
& .ic-raw {
|
||||
transition: transform 200ms ease-in-out;
|
||||
transform: rotate(45deg);
|
||||
background-color: var(--bg-caution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-reply {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-surface-low);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
|
||||
& .ic-btn-surface {
|
||||
margin: 0 13px 0 17px;
|
||||
border-radius: 0;
|
||||
[dir=rtl] & {
|
||||
margin: 0 17px 0 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
272
src/app/organisms/room/common.jsx
Normal file
272
src/app/organisms/room/common.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React from 'react';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
|
||||
|
||||
function getTimelineJSXMessages() {
|
||||
return {
|
||||
join(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' joined the room'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
leave(user, reason) {
|
||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' left the room'}
|
||||
{reasonMsg}
|
||||
</>
|
||||
);
|
||||
},
|
||||
invite(inviter, user) {
|
||||
return (
|
||||
<>
|
||||
<b>{inviter}</b>
|
||||
{' invited '}
|
||||
<b>{user}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
cancelInvite(inviter, user) {
|
||||
return (
|
||||
<>
|
||||
<b>{inviter}</b>
|
||||
{' canceled '}
|
||||
<b>{user}</b>
|
||||
{'\'s invite'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
rejectInvite(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' rejected the invitation'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
kick(actor, user, reason) {
|
||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
||||
return (
|
||||
<>
|
||||
<b>{actor}</b>
|
||||
{' kicked '}
|
||||
<b>{user}</b>
|
||||
{reasonMsg}
|
||||
</>
|
||||
);
|
||||
},
|
||||
ban(actor, user, reason) {
|
||||
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
|
||||
return (
|
||||
<>
|
||||
<b>{actor}</b>
|
||||
{' banned '}
|
||||
<b>{user}</b>
|
||||
{reasonMsg}
|
||||
</>
|
||||
);
|
||||
},
|
||||
unban(actor, user) {
|
||||
return (
|
||||
<>
|
||||
<b>{actor}</b>
|
||||
{' unbanned '}
|
||||
<b>{user}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
avatarSets(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' set the avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
avatarChanged(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' changed the avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
avatarRemoved(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' removed the avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
nameSets(user, newName) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' set the display name to '}
|
||||
<b>{newName}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
nameChanged(user, newName) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' changed the display name to '}
|
||||
<b>{newName}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
nameRemoved(user, lastName) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' removed the display name '}
|
||||
<b>{lastName}</b>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getUsersActionJsx(roomId, userIds, actionStr) {
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
const getUserDisplayName = (userId) => {
|
||||
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
|
||||
return getUsername(userId);
|
||||
};
|
||||
const getUserJSX = (userId) => <b>{getUserDisplayName(userId)}</b>;
|
||||
if (!Array.isArray(userIds)) return 'Idle';
|
||||
if (userIds.length === 0) return 'Idle';
|
||||
const MAX_VISIBLE_COUNT = 3;
|
||||
|
||||
const u1Jsx = getUserJSX(userIds[0]);
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
|
||||
|
||||
const u2Jsx = getUserJSX(userIds[1]);
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
|
||||
|
||||
const u3Jsx = getUserJSX(userIds[2]);
|
||||
if (userIds.length === 3) {
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
|
||||
}
|
||||
|
||||
const othersCount = userIds.length - MAX_VISIBLE_COUNT;
|
||||
// eslint-disable-next-line react/jsx-one-expression-per-line
|
||||
return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}</>;
|
||||
}
|
||||
|
||||
function parseReply(rawContent) {
|
||||
if (rawContent.indexOf('>') !== 0) return null;
|
||||
let content = rawContent.slice(rawContent.indexOf('<') + 1);
|
||||
const user = content.slice(0, content.indexOf('>'));
|
||||
|
||||
content = content.slice(content.indexOf('>') + 2);
|
||||
const replyContent = content.slice(0, content.indexOf('\n\n'));
|
||||
content = content.slice(content.indexOf('\n\n') + 2);
|
||||
|
||||
if (user === '') return null;
|
||||
|
||||
const isUserId = user.match(/^@.+:.+/);
|
||||
|
||||
return {
|
||||
userId: isUserId ? user : null,
|
||||
displayName: isUserId ? null : user,
|
||||
replyContent,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimelineChange(mEvent) {
|
||||
const tJSXMsgs = getTimelineJSXMessages();
|
||||
const makeReturnObj = (variant, content) => ({
|
||||
variant,
|
||||
content,
|
||||
});
|
||||
const content = mEvent.getContent();
|
||||
const prevContent = mEvent.getPrevContent();
|
||||
const sender = mEvent.getSender();
|
||||
const senderName = getUsername(sender);
|
||||
const userName = getUsername(mEvent.getStateKey());
|
||||
|
||||
switch (content.membership) {
|
||||
case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
|
||||
case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
|
||||
case 'join':
|
||||
if (prevContent.membership === 'join') {
|
||||
if (content.displayname !== prevContent.displayname) {
|
||||
if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
|
||||
if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
|
||||
return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
|
||||
}
|
||||
if (content.avatar_url !== prevContent.avatar_url) {
|
||||
if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
|
||||
if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
|
||||
return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return makeReturnObj('join', tJSXMsgs.join(senderName));
|
||||
case 'leave':
|
||||
if (sender === mEvent.getStateKey()) {
|
||||
switch (prevContent.membership) {
|
||||
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
|
||||
default: return makeReturnObj('leave', tJSXMsgs.leave(senderName, content.reason));
|
||||
}
|
||||
}
|
||||
switch (prevContent.membership) {
|
||||
case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
|
||||
case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
|
||||
// sender is not target and made the target leave,
|
||||
// if not from invite/ban then this is a kick
|
||||
default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom(ref) {
|
||||
const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
ref.current.scrollTop = maxScrollTop;
|
||||
}
|
||||
|
||||
function isAtBottom(ref) {
|
||||
const { scrollHeight, scrollTop, offsetHeight } = ref.current;
|
||||
const scrollUptoBottom = scrollTop + offsetHeight;
|
||||
|
||||
// scroll view have to div inside div which contains messages
|
||||
const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild;
|
||||
const lastChildHeight = lastMessage.offsetHeight;
|
||||
|
||||
// auto scroll to bottom even if user has EXTRA_SPACE left to scroll
|
||||
const EXTRA_SPACE = 48;
|
||||
|
||||
if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function autoScrollToBottom(ref) {
|
||||
if (isAtBottom(ref)) scrollToBottom(ref);
|
||||
}
|
||||
|
||||
export {
|
||||
getTimelineJSXMessages,
|
||||
getUsersActionJsx,
|
||||
parseReply,
|
||||
parseTimelineChange,
|
||||
scrollToBottom,
|
||||
isAtBottom,
|
||||
autoScrollToBottom,
|
||||
};
|
||||
Reference in New Issue
Block a user