refactored ChannelView
This commit is contained in:
parent
2918d97fd0
commit
08d53d52e7
12 changed files with 1310 additions and 1226 deletions
File diff suppressed because it is too large
Load diff
|
@ -22,103 +22,6 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
&__sticky {
|
||||
min-height: 85px;
|
||||
position: relative;
|
||||
|
@ -126,123 +29,3 @@
|
|||
border-top: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
}
|
||||
|
||||
.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-left: var(--sp-extra-tight);
|
||||
[dir=rtl] & {
|
||||
margin-left: 0;
|
||||
margin-right: var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
& .scrollbar {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
&__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) calc(var(--sp-tight) - 2px);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channel-cmd-bar {
|
||||
--cmd-bar-height: 28px;
|
||||
min-height: var(--cmd-bar-height);
|
||||
|
||||
& .timeline-change {
|
||||
justify-content: flex-end;
|
||||
padding: var(--sp-ultra-tight) var(--sp-normal);
|
||||
|
||||
&__content {
|
||||
margin: 0;
|
||||
flex: unset;
|
||||
& > .text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
& b {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
62
src/app/organisms/channel/ChannelViewCmdBar.jsx
Normal file
62
src/app/organisms/channel/ChannelViewCmdBar.jsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ChannelViewCmdBar.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
|
||||
import TimelineChange from '../../molecules/message/TimelineChange';
|
||||
|
||||
import { getUsersActionJsx } from './common';
|
||||
|
||||
function ChannelViewCmdBar({ 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]);
|
||||
|
||||
return (
|
||||
<div className="channel-cmd-bar">
|
||||
{
|
||||
followingMembers.length !== 0 && (
|
||||
<TimelineChange
|
||||
variant="follow"
|
||||
content={getUsersActionJsx(followingMembers, 'following the conversation.')}
|
||||
time=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ChannelViewCmdBar.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default ChannelViewCmdBar;
|
22
src/app/organisms/channel/ChannelViewCmdBar.scss
Normal file
22
src/app/organisms/channel/ChannelViewCmdBar.scss
Normal file
|
@ -0,0 +1,22 @@
|
|||
.channel-cmd-bar {
|
||||
--cmd-bar-height: 28px;
|
||||
min-height: var(--cmd-bar-height);
|
||||
|
||||
& .timeline-change {
|
||||
justify-content: flex-end;
|
||||
padding: var(--sp-ultra-tight) var(--sp-normal);
|
||||
|
||||
&__content {
|
||||
margin: 0;
|
||||
flex: unset;
|
||||
& > .text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
& b {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
377
src/app/organisms/channel/ChannelViewContent.jsx
Normal file
377
src/app/organisms/channel/ChannelViewContent.jsx
Normal file
|
@ -0,0 +1,377 @@
|
|||
/* 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 { getUsername, doesRoomHaveUnread } from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { diffMinutes, isNotInSameDay } from '../../../util/common';
|
||||
|
||||
import Divider from '../../atoms/divider/Divider';
|
||||
import Message, { 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 { parseReply, parseTimelineChange } from './common';
|
||||
|
||||
const MAX_MSG_DIFF_MINUTES = 5;
|
||||
|
||||
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 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 renderMessage(mEvent) {
|
||||
function isMedia(mE) {
|
||||
return (
|
||||
mE.getContent()?.msgtype === 'm.file'
|
||||
|| mE.getContent()?.msgtype === 'm.image'
|
||||
|| mE.getContent()?.msgtype === 'm.audio'
|
||||
|| mE.getContent()?.msgtype === 'm.video'
|
||||
);
|
||||
}
|
||||
function genMediaContent(mE) {
|
||||
const mContent = mE.getContent();
|
||||
let mediaMXC = mContent.url;
|
||||
let thumbnailMXC = mContent?.info?.thumbnail_url;
|
||||
const isEncryptedFile = typeof mediaMXC === 'undefined';
|
||||
if (isEncryptedFile) mediaMXC = mContent.file.url;
|
||||
|
||||
switch (mE.getContent()?.msgtype) {
|
||||
case 'm.file':
|
||||
return (
|
||||
<Media.File
|
||||
name={mContent.body}
|
||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||
file={mContent.file}
|
||||
type={mContent.info.mimetype}
|
||||
/>
|
||||
);
|
||||
case 'm.image':
|
||||
return (
|
||||
<Media.Image
|
||||
name={mContent.body}
|
||||
width={mContent.info.w || null}
|
||||
height={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}
|
||||
/>
|
||||
);
|
||||
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={mContent.info.w || null}
|
||||
height={mContent.info.h || null}
|
||||
file={isEncryptedFile ? mContent.file : null}
|
||||
type={mContent.info.mimetype}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return 'Unable to attach media file!';
|
||||
}
|
||||
}
|
||||
|
||||
if (mEvent.getType() === 'm.room.create') {
|
||||
const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
return (
|
||||
<ChannelIntro
|
||||
key={mEvent.getId()}
|
||||
avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
|
||||
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={`Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
mEvent.getType() !== 'm.room.message'
|
||||
&& mEvent.getType() !== 'm.room.encrypted'
|
||||
&& mEvent.getType() !== 'm.room.member'
|
||||
) 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 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 username = getUsername(parsedContent.userId);
|
||||
reply = {
|
||||
color: colorMXID(parsedContent.userId),
|
||||
to: username,
|
||||
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].count += 1;
|
||||
if (reactions[i].active !== true) {
|
||||
reactions[i].active = rEvent.getSender() === initMatrix.matrixClient.getUserId();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reactions.push({
|
||||
id: rEvent.getId(),
|
||||
key: rEvent.getRelation().key,
|
||||
count: 1,
|
||||
active: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const myMessageEl = (
|
||||
<React.Fragment key={`box-${mEvent.getId()}`}>
|
||||
{divider}
|
||||
{ isMedia(mEvent) ? (
|
||||
<Message
|
||||
key={mEvent.getId()}
|
||||
contentOnly={isContentOnly}
|
||||
markdown={isMarkdown}
|
||||
avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
|
||||
color={colorMXID(mEvent.sender.userId)}
|
||||
name={getUsername(mEvent.sender.userId)}
|
||||
content={genMediaContent(mEvent)}
|
||||
reply={reply}
|
||||
time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
|
||||
edited={isEdited}
|
||||
reactions={reactions}
|
||||
/>
|
||||
) : (
|
||||
<Message
|
||||
key={mEvent.getId()}
|
||||
contentOnly={isContentOnly}
|
||||
markdown={isMarkdown}
|
||||
avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
|
||||
color={colorMXID(mEvent.sender.userId)}
|
||||
name={getUsername(mEvent.sender.userId)}
|
||||
content={content}
|
||||
reply={reply}
|
||||
time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
|
||||
edited={isEdited}
|
||||
reactions={reactions}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
prevMEvent = mEvent;
|
||||
return myMessageEl;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
return (
|
||||
<div className="channel-view__content">
|
||||
<div className="timeline__wrapper">
|
||||
{
|
||||
roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && (
|
||||
<>
|
||||
<PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
|
||||
<PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
|
||||
<PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && (
|
||||
<ChannelIntro
|
||||
key={Math.random().toString(20).substr(2, 6)}
|
||||
avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
|
||||
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}`) : ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{ roomTimeline.timeline.map(renderMessage) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ChannelViewContent.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,
|
||||
isScrollable: PropTypes.func,
|
||||
}).isRequired,
|
||||
viewEvent: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default ChannelViewContent;
|
13
src/app/organisms/channel/ChannelViewContent.scss
Normal file
13
src/app/organisms/channel/ChannelViewContent.scss
Normal file
|
@ -0,0 +1,13 @@
|
|||
.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);
|
||||
}
|
||||
}
|
83
src/app/organisms/channel/ChannelViewFloating.jsx
Normal file
83
src/app/organisms/channel/ChannelViewFloating.jsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
/* 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([...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;
|
84
src/app/organisms/channel/ChannelViewFloating.scss
Normal file
84
src/app/organisms/channel/ChannelViewFloating.scss
Normal file
|
@ -0,0 +1,84 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
}
|
61
src/app/organisms/channel/ChannelViewHeader.jsx
Normal file
61
src/app/organisms/channel/ChannelViewHeader.jsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
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 avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
|
||||
const roomName = mx.getRoom(roomId).name;
|
||||
const isDM = initMatrix.roomList.directs.has(roomId);
|
||||
const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomName)} 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, isDM)}>Leave</MenuItem>
|
||||
</>
|
||||
)}
|
||||
render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
|
||||
/>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
ChannelViewHeader.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ChannelViewHeader;
|
234
src/app/organisms/channel/ChannelViewInput.jsx
Normal file
234
src/app/organisms/channel/ChannelViewInput.jsx
Normal file
|
@ -0,0 +1,234 @@
|
|||
/* 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 { bytesToSize } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import ContextMenu from '../../atoms/context-menu/ContextMenu';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import EmojiBoard from '../emoji-board/EmojiBoard';
|
||||
|
||||
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 FileIC from '../../../../public/res/ic/outlined/file.svg';
|
||||
|
||||
let isTyping = false;
|
||||
function ChannelViewInput({
|
||||
roomId, roomTimeline, timelineScroll, viewEvent,
|
||||
}) {
|
||||
const [attachment, setAttachment] = useState(null);
|
||||
|
||||
const textAreaRef = useRef(null);
|
||||
const inputBaseRef = useRef(null);
|
||||
const uploadInputRef = useRef(null);
|
||||
const uploadProgressRef = useRef(null);
|
||||
|
||||
const TYPING_TIMEOUT = 5000;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomsInput } = initMatrix;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
if (textAreaRef?.current !== null) {
|
||||
isTyping = false;
|
||||
textAreaRef.current.focus();
|
||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
||||
setAttachment(roomsInput.getAttachment(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);
|
||||
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';
|
||||
textAreaRef.current.focus();
|
||||
|
||||
textAreaRef.current.value = roomsInput.getMessage(roomId);
|
||||
timelineScroll.reachBottom();
|
||||
viewEvent.emit('message_sent');
|
||||
textAreaRef.current.style.height = 'unset';
|
||||
}
|
||||
|
||||
function processTyping(msg) {
|
||||
const isEmptyMsg = msg === '';
|
||||
|
||||
if (isEmptyMsg && isTyping) {
|
||||
sendIsTyping(false);
|
||||
return;
|
||||
}
|
||||
if (!isEmptyMsg && !isTyping) {
|
||||
sendIsTyping(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMsgTyping(e) {
|
||||
const msg = e.target.value;
|
||||
processTyping(msg);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.keyCode === 13 && e.shiftKey === false) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
<div className="channel-input__option-container">
|
||||
<ContextMenu
|
||||
placement="top"
|
||||
content={(
|
||||
<EmojiBoard onSelect={addEmoji} />
|
||||
)}
|
||||
render={(toggleMenu) => <IconButton onClick={toggleMenu} 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ 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;
|
96
src/app/organisms/channel/ChannelViewInput.scss
Normal file
96
src/app/organisms/channel/ChannelViewInput.scss
Normal file
|
@ -0,0 +1,96 @@
|
|||
.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-left: var(--sp-extra-tight);
|
||||
[dir=rtl] & {
|
||||
margin-left: 0;
|
||||
margin-right: var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
& .scrollbar {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
&__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) calc(var(--sp-tight) - 2px);
|
||||
|
||||
&::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);
|
||||
}
|
||||
}
|
||||
}
|
261
src/app/organisms/channel/common.jsx
Normal file
261
src/app/organisms/channel/common.jsx
Normal file
|
@ -0,0 +1,261 @@
|
|||
import React from 'react';
|
||||
|
||||
import { getUsername } from '../../../util/matrixUtil';
|
||||
|
||||
function getTimelineJSXMessages() {
|
||||
return {
|
||||
join(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' joined the channel'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
leave(user) {
|
||||
return (
|
||||
<>
|
||||
<b>{user}</b>
|
||||
{' left the channel'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
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') ? ` for ${reason}` : '';
|
||||
return (
|
||||
<>
|
||||
<b>{actor}</b>
|
||||
{' kicked '}
|
||||
<b>{user}</b>
|
||||
{reasonMsg}
|
||||
</>
|
||||
);
|
||||
},
|
||||
ban(actor, user, reason) {
|
||||
const reasonMsg = (typeof reason === 'string') ? ` for ${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(userIds, actionStr) {
|
||||
const getUserJSX = (username) => <b>{getUsername(username)}</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('@'));
|
||||
const userId = 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 (userId === '') return null;
|
||||
|
||||
return {
|
||||
userId,
|
||||
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));
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
Loading…
Reference in a new issue