Refactor: Make all chat area use graphql (#18122)

* Refactor: Make all chat area use graphql

* Fix: large space between welcome msg and chat list

* Fix: missing file

* add pending status and fix system messages

* Add: mark messages as seen in chat

* Refactor: Move char opening logic to inside of chat panel

* Refactor message and mark as seen

* Add Recharts to package.json and fix miss data

* Implements clear-chat function on graphql

* Make system message sticky

* Add clear message support and fix user is typing

* FIx chat unread and scroll not following the tail

* Change: make unread messages be marked by message and fix throttle

* Don't show restore welcome message when the welcome message isn't set

* Fix: scroll not following the tail properly

* Fix: previous page last sender not working

* Fix: scroll loading all messages

* Fix messaga not marked as read

---------

Co-authored-by: Gustavo Trott <gustavo@trott.com.br>
This commit is contained in:
Tainan Felipe 2023-07-07 17:46:36 -03:00 committed by GitHub
parent 8fde225ade
commit f2e0fd43e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 3223 additions and 2207 deletions

View File

@ -3,6 +3,7 @@ package org.bigbluebutton.core.apps.chat
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.{ ChatModel, PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.db.ChatMessageDAO
import org.bigbluebutton.core.running.{ LiveMeeting, LogHelper }
import org.bigbluebutton.core.domain.MeetingState2x
@ -31,6 +32,8 @@ trait ClearPublicChatHistoryPubMsgHdlr extends LogHelper with RightsManagementTr
val newState = for {
gc <- state.groupChats.find(msg.body.chatId)
} yield {
ChatMessageDAO.deleteAllFromChat(liveMeeting.props.meetingProp.intId, msg.body.chatId)
ChatMessageDAO.insertSystemMsg(liveMeeting.props.meetingProp.intId, msg.body.chatId, "", "publicChatHistoryCleared", Map(), "")
broadcastEvent(msg)
val newGc = gc.clearMessages()
val gcs = state.groupChats.update(newGc)

View File

@ -99,4 +99,16 @@ object ChatMessageDAO {
}
}
def deleteAllFromChat(meetingId: String, chatId: String) = {
DatabaseConnection.db.run(
TableQuery[ChatMessageDbTableDef]
.filter(_.meetingId === meetingId)
.filter(_.chatId === chatId)
.delete
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) deleted from ChatMessage meetingId=${meetingId} chatId=${chatId}")
case Failure(e) => DatabaseConnection.logger.error(s"Error deleting from ChatMessage meetingId=${meetingId} chatId=${chatId}: $e")
}
}
}

View File

@ -5,8 +5,8 @@ import handleUserTyping from './handlers/userTyping';
import handleSyncGroupChatMsg from './handlers/syncGroupsChat';
import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers';
RedisPubSub.on('GetGroupChatMsgsRespMsg', processForHTML5ServerOnly(handleSyncGroupChatMsg));
RedisPubSub.on('GroupChatMessageBroadcastEvtMsg', handleGroupChatMsgBroadcast);
RedisPubSub.on('ClearPublicChatHistoryEvtMsg', handleClearPublicGroupChat);
RedisPubSub.on('SyncGetGroupChatMsgsRespMsg', handleSyncGroupChatMsg);
RedisPubSub.on('UserTypingEvtMsg', handleUserTyping);
// RedisPubSub.on('GetGroupChatMsgsRespMsg', processForHTML5ServerOnly(handleSyncGroupChatMsg));
// RedisPubSub.on('GroupChatMessageBroadcastEvtMsg', handleGroupChatMsgBroadcast);
// RedisPubSub.on('ClearPublicChatHistoryEvtMsg', handleClearPublicGroupChat);
// RedisPubSub.on('SyncGetGroupChatMsgsRespMsg', handleSyncGroupChatMsg);
// RedisPubSub.on('UserTypingEvtMsg', handleUserTyping);

View File

@ -4,7 +4,7 @@ import handleGroupChatCreated from './handlers/groupChatCreated';
import handleGroupChatDestroyed from './handlers/groupChatDestroyed';
import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers';
RedisPubSub.on('GetGroupChatsRespMsg', processForHTML5ServerOnly(handleGroupChats));
RedisPubSub.on('GroupChatCreatedEvtMsg', handleGroupChatCreated);
RedisPubSub.on('GroupChatDestroyedEvtMsg', handleGroupChatDestroyed);
RedisPubSub.on('SyncGetGroupChatsRespMsg', handleGroupChats);
// RedisPubSub.on('GetGroupChatsRespMsg', processForHTML5ServerOnly(handleGroupChats));
// RedisPubSub.on('GroupChatCreatedEvtMsg', handleGroupChatCreated);
// RedisPubSub.on('GroupChatDestroyedEvtMsg', handleGroupChatDestroyed);
// RedisPubSub.on('SyncGetGroupChatsRespMsg', handleGroupChats);

View File

@ -8,4 +8,5 @@ export interface Chat {
totalUnread: number;
userId: string;
participant?: User;
lastSeenAt: number;
}

View File

@ -8,9 +8,11 @@ export interface Message {
createdTimeAsDate: string;
meetingId: string;
message: string;
messageType: string;
messageId: string;
senderId: string;
senderName: string;
senderRole: string;
messageMetadata: string;
user: User;
}
}

View File

@ -9,6 +9,8 @@ import { uid } from 'radash';
import Button from '/imports/ui/components/common/button/component';
import { clearPublicChatHistory, generateExportedMessages } from './services'
import { getDateString } from '/imports/utils/string-utils';
import Events from '/imports/ui/core/events/events';
import { isEmpty } from 'ramda';
const CHAT_CONFIG = Meteor.settings.public.chat;
const ENABLE_SAVE_AND_COPY_PUBLIC_CHAT = CHAT_CONFIG.enableSaveAndCopyPublicChat;
@ -38,15 +40,20 @@ const intlMessages = defineMessages({
id: 'app.chat.dropdown.options',
description: 'Chat Options',
},
showWelcomeMessage: {
id: 'app.chat.dropdown.showWelcomeMessage',
description: 'Restore button label',
},
});
export const ChatActions: React.FC = () => {
const intl = useIntl();
const isRTL = layoutSelect((i: Layout) => i.isRTL);
const uniqueIdsRef = useRef<string[]>([uid(1), uid(2), uid(3)]);
const uniqueIdsRef = useRef<string[]>([uid(1), uid(2), uid(3), uid(4)]);
const downloadOrCopyRef = useRef<'download' | 'copy' | null>(null);
const [userIsModerator, setUserIsmoderator] = useState<boolean>(false);
const [meetingIsBreakout, setMeetingIsBreakout] = useState<boolean>(false);
const [showShowWelcomeMessages, setShowShowWelcomeMessages] = useState<boolean>(false);
const [
getChatMessageHistory,
{
@ -65,7 +72,6 @@ export const ChatActions: React.FC = () => {
useEffect(() => {
if (dataHistory) {
console.log('dataHistory', dataHistory);
const exportedString = generateExportedMessages(
dataHistory.chat_message_public,
dataHistory.user_welcomeMsgs[0],
@ -92,47 +98,61 @@ export const ChatActions: React.FC = () => {
if (dataPermissions) {
setUserIsmoderator(dataPermissions.user_current[0].isModerator);
setMeetingIsBreakout(dataPermissions.meeting[0].isBreakout);
if (!isEmpty(dataPermissions.user_welcomeMsgs[0].welcomeMsg)) {
setShowShowWelcomeMessages(true);
}
}
}, [dataPermissions]);
const actions = useMemo(()=>{
const dropdownActions = [
{
key: uniqueIdsRef.current[0],
enable: ENABLE_SAVE_AND_COPY_PUBLIC_CHAT,
icon: 'download',
dataTest: 'chatSave',
label: intl.formatMessage(intlMessages.save),
onClick: () => {
getChatMessageHistory();
downloadOrCopyRef.current = 'download';
},
},
{
key: uniqueIdsRef.current[1],
enable: ENABLE_SAVE_AND_COPY_PUBLIC_CHAT,
icon: 'copy',
id: 'clipboardButton',
dataTest: 'chatCopy',
label: intl.formatMessage(intlMessages.copy),
onClick: () => {
getChatMessageHistory();
downloadOrCopyRef.current = 'copy';
},
},
{
key: uniqueIdsRef.current[2],
enable: userIsModerator && !meetingIsBreakout,
icon: 'download',
dataTest: 'chatClear',
label: intl.formatMessage(intlMessages.clear),
onClick: () => clearPublicChatHistory(),
},
];
return dropdownActions.filter((action) => action.enable);
},[userIsModerator, meetingIsBreakout]);
if (errorHistory) return <p>Error loading chat history: {JSON.stringify(errorHistory)}</p>;
if (errorPermissions) return <p>Error loading permissions: {JSON.stringify(errorPermissions)}</p>;
const actions = useMemo(() => {
const dropdownActions = [
{
key: uniqueIdsRef.current[0],
enable: ENABLE_SAVE_AND_COPY_PUBLIC_CHAT,
icon: 'download',
dataTest: 'chatSave',
label: intl.formatMessage(intlMessages.save),
onClick: () => {
getChatMessageHistory();
downloadOrCopyRef.current = 'download';
},
},
{
key: uniqueIdsRef.current[1],
enable: ENABLE_SAVE_AND_COPY_PUBLIC_CHAT,
icon: 'copy',
id: 'clipboardButton',
dataTest: 'chatCopy',
label: intl.formatMessage(intlMessages.copy),
onClick: () => {
getChatMessageHistory();
downloadOrCopyRef.current = 'copy';
},
},
{
key: uniqueIdsRef.current[2],
enable: userIsModerator && !meetingIsBreakout,
icon: 'download',
dataTest: 'chatClear',
label: intl.formatMessage(intlMessages.clear),
onClick: () => clearPublicChatHistory(),
},
{
key: uniqueIdsRef.current[4],
enable: showShowWelcomeMessages,
icon: 'about',
dataTest: 'restoreWelcomeMessages',
label: intl.formatMessage(intlMessages.showWelcomeMessage),
onClick: () => {
const restoreWelcomeMessagesEvent = new CustomEvent(Events.RESTORE_WELCOME_MESSAGES);
window.dispatchEvent(restoreWelcomeMessagesEvent);
},
},
];
return dropdownActions.filter((action) => action.enable);
}, [userIsModerator, meetingIsBreakout]);
if (errorHistory) return <p>Error loading chat history: {JSON.stringify(errorHistory)}</p>;
if (errorPermissions) return <p>Error loading permissions: {JSON.stringify(errorPermissions)}</p>;
return (
<BBBMenu
trigger={
@ -150,15 +170,15 @@ export const ChatActions: React.FC = () => {
}
opts={{
id: 'chat-options-dropdown-menu',
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getContentAnchorEl: null,
fullwidth: 'true',
anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
}}
actions={actions}
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getContentAnchorEl: null,
fullwidth: 'true',
anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
}}
actions={actions}
/>
);
}

View File

@ -10,8 +10,9 @@ export type getChatMessageHistory = {
};
export type getPermissions = {
user_current: Array<User>,
meeting: Array<{isBreakout: boolean}>
user_current: Array<User>;
meeting: Array<{ isBreakout: boolean }>;
user_welcomeMsgs: Array<{ welcomeMsg: string; welcomeMsgForModerators: string | null }>;
};
export const GET_CHAT_MESSAGE_HISTORY = gql`
@ -37,13 +38,17 @@ query getChatMessageHistory {
`;
export const GET_PERMISSIONS = gql`
query getPermissions {
user_current {
isModerator
query getPermissions {
user_current {
isModerator
}
meeting {
isBreakout
name
}
user_welcomeMsgs {
welcomeMsg
welcomeMsgForModerators
}
}
meeting {
isBreakout
name
}
}
`;

View File

@ -1,13 +1,13 @@
import React from 'react';
import Header from '/imports/ui/components/common/control-header/component';
import { useQuery } from '@apollo/client';
import { GET_CHAT_DATA, GetChatDataResponse } from './queries';
import { useMutation, useQuery } from '@apollo/client';
import { GET_CHAT_DATA, GetChatDataResponse, CLOSE_PRIVATE_CHAT_MUTATION } from './queries';
import { defineMessages, useIntl } from 'react-intl';
import { closePrivateChat } from './services';
import { layoutSelect, layoutDispatch } from '../../../layout/context';
import { useShortcutHelp } from '../../../shortcut-help/useShortcutHelp';
import { Layout } from '../../../layout/layoutTypes';
import { ACTIONS, PANELS } from '../../../layout/enums';
import { ACTIONS, PANELS } from '../../../layout/enums';
import { ChatActions } from './chat-actions/component';
interface ChatHeaderProps {
@ -35,59 +35,61 @@ const intlMessages = defineMessages({
},
});
const ChatHeader: React.FC<ChatHeaderProps> = ({ chatId, isPublicChat, title}) => {
const ChatHeader: React.FC<ChatHeaderProps> = ({ chatId, isPublicChat, title }) => {
const HIDE_CHAT_AK = useShortcutHelp('hideprivatechat');
const CLOSE_CHAT_AK = useShortcutHelp('closeprivatechat');
const layoutContextDispatch = layoutDispatch();
const intl = useIntl();
const [updateVisible] = useMutation(CLOSE_PRIVATE_CHAT_MUTATION);
return (
<Header
data-test="chatTitle"
leftButtonProps={{
accessKey: chatId !== 'public' ? HIDE_CHAT_AK : null,
'aria-label': intl.formatMessage(intlMessages.hideChatLabel, { 0: title }),
'data-test': isPublicChat ? 'hidePublicChat' : 'hidePrivateChat',
label: title,
onClick: () => {
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_ID_CHAT_OPEN,
value: '',
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
},
}}
rightButtonProps={{
accessKey: CLOSE_CHAT_AK,
'aria-label': intl.formatMessage(intlMessages.closeChatLabel, { 0: title }),
'data-test': 'closePrivateChat',
icon: 'close',
label: intl.formatMessage(intlMessages.closeChatLabel, { 0: title }),
onClick: () => {
closePrivateChat(chatId);
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_ID_CHAT_OPEN,
value: '',
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
},
}}
customRightButton={isPublicChat? <ChatActions /> : null}
/>
data-test="chatTitle"
leftButtonProps={{
accessKey: chatId !== 'public' ? HIDE_CHAT_AK : null,
'aria-label': intl.formatMessage(intlMessages.hideChatLabel, { 0: title }),
'data-test': isPublicChat ? 'hidePublicChat' : 'hidePrivateChat',
label: title,
onClick: () => {
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_ID_CHAT_OPEN,
value: '',
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
},
}}
rightButtonProps={{
accessKey: CLOSE_CHAT_AK,
'aria-label': intl.formatMessage(intlMessages.closeChatLabel, { 0: title }),
'data-test': 'closePrivateChat',
icon: 'close',
label: intl.formatMessage(intlMessages.closeChatLabel, { 0: title }),
onClick: () => {
updateVisible({ variables: { chatId } });
closePrivateChat(chatId);
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_ID_CHAT_OPEN,
value: '',
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
},
}}
customRightButton={isPublicChat ? <ChatActions /> : null}
/>
);
};
@ -95,7 +97,7 @@ const isChatResponse = (data: unknown): data is GetChatDataResponse => {
return (data as GetChatDataResponse).chat !== undefined;
};
const ChatHeaderContainer: React.FC<ChatHeaderProps> = () => {
const ChatHeaderContainer: React.FC = () => {
const intl = useIntl();
const idChatOpen = layoutSelect((i: Layout) => i.idChatOpen);
const {

View File

@ -6,20 +6,28 @@ type chatContent = {
participant: {
name: string;
};
}
};
export interface GetChatDataResponse {
chat: Array<chatContent>;
}
}
export const GET_CHAT_DATA = gql`
query GetChatData($chatId: String!) {
chat(where: {chatId: {_eq: $chatId }}) {
chatId
public
participant {
name
query GetChatData($chatId: String!) {
chat(where: { chatId: { _eq: $chatId } }) {
chatId
public
participant {
name
}
}
}
}
`;
`;
export const CLOSE_PRIVATE_CHAT_MUTATION = gql`
mutation UpdateChatUser($chatId: String) {
update_chat_user(where: { chatId: { _eq: $chatId } }, _set: { visible: false }) {
affected_rows
}
}
`;

View File

@ -0,0 +1,29 @@
import React from "react";
import Styled from './styles';
import { defineMessages, useIntl } from "react-intl";
interface ChatOfflineIndicatorProps {
participantName: string;
}
const intlMessages = defineMessages({
partnerDisconnected: {
id: 'app.chat.partnerDisconnected',
description: 'System chat message when the private chat partnet disconnect from the meeting',
},
});
const ChatOfflineIndicator: React.FC<ChatOfflineIndicatorProps> = ({
participantName,
}) => {
const intl = useIntl();
return (
<Styled.ChatOfflineIndicator>
<span>
{intl.formatMessage(intlMessages.partnerDisconnected, { 0: participantName })}
</span>
</Styled.ChatOfflineIndicator>
);
};
export default ChatOfflineIndicator;

View File

@ -0,0 +1,21 @@
import styled from 'styled-components';
import { colorText, colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
export const ChatOfflineIndicator = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
position: relative;
margin-top: 0.2rem;
padding: 0.5rem;
border-radius: 2px;
border-top: 1px solid ${colorGrayLighter};
& > span {
color: ${colorText};
font-size: 1rem;
}
`;
export default {
ChatOfflineIndicator,
};

View File

@ -7,22 +7,25 @@ import ClickOutside from '/imports/ui/components/click-outside/component';
import Styled from './styles';
import { escapeHtml } from '/imports/utils/string-utils';
import { checkText } from 'smile2emoji';
import TypingIndicatorContainer from '/imports/ui/components/chat/message-form/typing-indicator/container';
import deviceInfo from '/imports/utils/deviceInfo';
import { usePreviousValue } from '/imports/ui/components/utils/hooks';
import useChat from '/imports/ui/core/hooks/useChat';
import {
handleSendMessage,
startUserTyping,
stopUserTyping,
} from './service';
import { Chat } from '/imports/ui/Types/chat';
import { Layout } from '../../../layout/layoutTypes';
import { useMeeting } from '/imports/ui/core/hooks/useMeeting';
import Events from '/imports/ui/core/events/events';
import ChatOfflineIndicator from './chat-offline-indicator/component';
interface ChatMessageFormProps {
minMessageLength: number,
maxMessageLength: number,
idChatOpen: string,
chatAreaId: string,
chatId: string,
chatTitle: string,
connected: boolean,
disabled: boolean,
locked: boolean,
@ -69,6 +72,18 @@ const messages = defineMessages({
id: 'app.chat.severalPeople',
description: 'displayed when 4 or more users are typing',
},
titlePublic: {
id: 'app.chat.titlePublic',
description: 'Public chat title',
},
titlePrivate: {
id: 'app.chat.titlePrivate',
description: 'Private chat title',
},
partnerDisconnected: {
id: 'app.chat.partnerDisconnected',
description: 'System chat message when the private chat partnet disconnect from the meeting',
},
});
const CHAT_CONFIG = Meteor.settings.public.chat;
@ -78,7 +93,6 @@ const ENABLE_TYPING_INDICATOR = CHAT_CONFIG.typingIndicator.enabled;
const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
handleClickOutside,
chatTitle,
title,
disabled,
idChatOpen,
@ -98,11 +112,9 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
const textAreaRef = useRef();
const { isMobile } = deviceInfo;
const prevChatId = usePreviousValue(chatId);
const messageRef = useRef();
messageRef.current = message;
const updateUnreadMessages = (chatId, message) => {
const updateUnreadMessages = (chatId: string, message: string) => {
const storedData = localStorage.getItem('unsentMessages') || '{}';
const unsentMessages = JSON.parse(storedData);
unsentMessages[chatId] = message;
@ -118,14 +130,14 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
return () => {
const unsentMessage = messageRef.current;
updateUnreadMessages(chatId, unsentMessage);
}
}
}, []);
useEffect(() => {
const storedData = localStorage.getItem('unsentMessages') || '{}';
const unsentMessages = JSON.parse(storedData);
if(prevChatId) {
if (prevChatId) {
updateUnreadMessages(prevChatId, message);
}
@ -178,17 +190,19 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
updateUnreadMessages(chatId, '');
setHasErrors(false);
setShowEmojiPicker(false);
if(ENABLE_TYPING_INDICATOR) stopUserTyping();
if (ENABLE_TYPING_INDICATOR) stopUserTyping();
const sentMessageEvent = new CustomEvent(Events.SENT_MESSAGE);
window.dispatchEvent(sentMessageEvent);
}
const handleEmojiSelect = (emojiObject: { native: string} ) => {
const handleEmojiSelect = (emojiObject: { native: string }) => {
const txtArea = textAreaRef?.current?.textarea;
const cursor = txtArea.selectionStart;
setMessage(
message.slice(0, cursor)
+ emojiObject.native
+ message.slice(cursor)
+ emojiObject.native
+ message.slice(cursor)
);
const newCursor = cursor + emojiObject.native.length;
@ -198,7 +212,6 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
const handleMessageChange = (e: React.FormEvent<HTMLInputElement>) => {
let newMessage = null;
let newError = null;
if (AUTO_CONVERT_EMOJI) {
newMessage = checkText(e.target.value);
} else {
@ -215,7 +228,7 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
setMessage(newMessage);
setError(newError);
throttledHandleUserTyping(newError);
handleUserTyping(newError)
}
const handleUserTyping = (error?: boolean) => {
@ -223,9 +236,6 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
startUserTyping(chatId);
}
const throttledHandleUserTyping = throttle(() => handleUserTyping(),
2000, { trailing: false });
const handleMessageKeyDown = (e: React.FormEvent<HTMLInputElement>) => {
// TODO Prevent send message pressing enter on mobile and/or virtual keyboard
if (e.keyCode === 13 && !e.shiftKey) {
@ -262,7 +272,7 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
id="message-input"
ref={textAreaRef}
placeholder={intl.formatMessage(messages.inputPlaceholder, { 0: title })}
aria-label={intl.formatMessage(messages.inputLabel, { 0: chatTitle })}
aria-label={intl.formatMessage(messages.inputLabel, { 0: title })}
aria-invalid={hasErrors ? 'true' : 'false'}
autoCorrect="off"
autoComplete="off"
@ -302,7 +312,14 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
data-test="sendMessageButton"
/>
</Styled.Wrapper>
<TypingIndicatorContainer {...{ idChatOpen, error }} />
{
error && (
<Styled.Error>
{error}
</Styled.Error>
)
}
</Styled.Form>
);
}
@ -317,17 +334,45 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
};
const ChatMessageFormContainer: React.FC = ({
chatAreaId,
chatId,
chatTitle,
connected,
disabled,
partnerIsLoggedOut,
title,
locked,
// connected, move to network status
}) => {
const intl = useIntl();
const [showEmojiPicker, setShowEmojiPicker] = React.useState(false);
const idChatOpen = layoutSelect((i) => i.idChatOpen);
const idChatOpen: string = layoutSelect((i: Layout) => i.idChatOpen);
const chat = useChat((c: Partial<Chat>) => {
const participant = c?.participant ? {
participant: {
name: c?.participant?.name,
isModerator: c?.participant?.isModerator,
isOnline: c?.participant?.isOnline,
}
} : {};
return {
...participant,
chatId: c?.chatId,
public: c?.public,
};
}, idChatOpen) as Partial<Chat>;
const title = chat?.participant?.name
? intl.formatMessage(messages.titlePrivate, { 0: chat?.participant?.name })
: intl.formatMessage(messages.titlePublic);
const meeting = useMeeting((m) => {
return {
lockSettings: {
hasActiveLockSetting: m?.lockSettings?.hasActiveLockSetting,
disablePublicChat: m?.lockSettings?.disablePublicChat,
disablePrivateChat: m?.lockSettings?.disablePrivateChat,
}
};
});
const locked = chat?.public
? meeting?.lockSettings?.disablePublicChat
: meeting?.lockSettings?.disablePrivateChat;
const handleClickOutside = () => {
if (showEmojiPicker) {
@ -335,20 +380,23 @@ const ChatMessageFormContainer: React.FC = ({
}
};
if (chat?.participant && !chat.participant.isOnline) {
return <ChatOfflineIndicator participantName={chat.participant.name} />;
}
return <ChatMessageForm
{...{
minMessageLength: CHAT_CONFIG.min_message_length,
maxMessageLength: CHAT_CONFIG.max_message_length,
idChatOpen,
handleClickOutside,
chatAreaId,
chatId,
chatTitle,
connected,
disabled,
partnerIsLoggedOut,
chatId: idChatOpen,
connected: true, //TODO: monitoring network status
disabled: locked ?? false,
title,
locked,
// if participant is not defined, it means that the chat is public
partnerIsLoggedOut: chat?.participant ? !chat?.participant?.isOnline : false,
locked: locked ?? false,
}}
/>;
};

View File

@ -1,58 +0,0 @@
import Auth from '/imports/ui/services/auth';
import Storage from '/imports/ui/services/storage/session';
import { makeCall } from '/imports/ui/services/api';
import { indexOf, without } from '/imports/utils/array-utils';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
const CHAT_EMPHASIZE_TEXT = CHAT_CONFIG.moderatorChatEmphasized;
const UnsentMessagesCollection = new Mongo.Collection(null);
export const UserSentMessageCollection = new Mongo.Collection(null);
// session for closed chat list
const CLOSED_CHAT_LIST_KEY = 'closedChatList';
const setUserSentMessage = (bool) => {
UserSentMessageCollection.upsert(
{ userId: Auth.userID },
{ $set: { sent: bool } },
);
};
const sendGroupMessage = (message, idChatOpen) => {
const { userID: senderUserId } = Auth;
const chatID = idChatOpen === PUBLIC_CHAT_ID
? PUBLIC_GROUP_CHAT_ID
: idChatOpen;
const receiverId = { id: chatID };
const payload = {
correlationId: `${senderUserId}-${Date.now()}`,
sender: {
id: senderUserId,
name: '',
role: '',
},
chatEmphasizedText: CHAT_EMPHASIZE_TEXT,
message,
};
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY);
// Remove the chat that user send messages from the session.
if (indexOf(currentClosedChats, receiverId.id) > -1) {
Storage.setItem(CLOSED_CHAT_LIST_KEY, without(currentClosedChats, receiverId.id));
}
return makeCall('sendGroupChatMsg', chatID, payload);
};
export default {
setUserSentMessage,
sendGroupMessage,
UnsentMessagesCollection,
};

View File

@ -9,16 +9,14 @@ const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
const CHAT_EMPHASIZE_TEXT = CHAT_CONFIG.moderatorChatEmphasized;
const START_TYPING_THROTTLE_INTERVAL = 2000;
const START_TYPING_THROTTLE_INTERVAL = 1000;
// session for closed chat list
const CLOSED_CHAT_LIST_KEY = 'closedChatList';
export const sendGroupMessage = (message: string, idChatOpen: string) => {
const { userID: senderUserId } = Auth;
const chatID = idChatOpen === PUBLIC_CHAT_ID
? PUBLIC_GROUP_CHAT_ID
: idChatOpen;
const chatID = idChatOpen === PUBLIC_CHAT_ID ? PUBLIC_GROUP_CHAT_ID : idChatOpen;
const receiverId = { id: chatID };
@ -43,14 +41,16 @@ export const sendGroupMessage = (message: string, idChatOpen: string) => {
return makeCall('sendGroupChatMsg', chatID, payload);
};
export const handleSendMessage = (message: string, idChatOpen: string) => {
return sendGroupMessage(message, idChatOpen);
};
export const startUserTyping = throttle(
(chatId: string) => makeCall('startUserTyping', chatId),
(chatId: string) => {
makeCall('startUserTyping', chatId);
},
START_TYPING_THROTTLE_INTERVAL,
{ leading: true, trailing: false }
);
export const stopUserTyping = () => makeCall('stopUserTyping');

View File

@ -4,6 +4,8 @@ import {
colorText,
colorGrayLighter,
colorPrimary,
colorDanger,
colorGrayDark,
} from '/imports/ui/stylesheets/styled-components/palette';
import {
smPaddingX,
@ -22,7 +24,6 @@ const Form = styled.form`
align-self: flex-end;
width: 100%;
position: relative;
margin-bottom: calc(-1 * ${smPaddingX});
margin-top: .2rem;
`;
@ -120,6 +121,19 @@ const EmojiPickerWrapper = styled.div`
}
`;
const Error = styled.div`
color: ${colorDanger};
font-size: calc(${fontSizeBase} * .75);
color: ${colorGrayDark};
text-align: left;
padding: ${borderSize} 0;
word-break: break-word;
position: relative;
margin-right: 0.05rem;
margin-left: 0.05rem;
`;
const EmojiPicker = styled(EmojiPickerComponent)``;
export default {
@ -131,4 +145,5 @@ export default {
EmojiButtonWrapper,
EmojiPicker,
EmojiPickerWrapper,
Error,
};

View File

@ -1,7 +1,7 @@
import React, { useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { Meteor } from "meteor/meteor";
import { useSubscription } from "@apollo/client";
import { CHAT_SUBSCRIPTION, ChatSubscriptionResponse } from "./queries";
import { makeVar, useMutation } from "@apollo/client";
import { LAST_SEEN_MUTATION } from "./queries";
import {
ButtonLoadMore,
MessageList,
@ -10,6 +10,13 @@ import {
import { layoutSelect } from "../../../layout/context";
import ChatListPage from "./page/component";
import { defineMessages, useIntl } from "react-intl";
import Events from "/imports/ui/core/events/events";
import useChat from "/imports/ui/core/hooks/useChat";
import { Chat } from "/imports/ui/Types/chat";
import { Message } from "/imports/ui/Types/message";
import { useCurrentUser } from "/imports/ui/core/hooks/useCurrentUser";
import { User } from "/imports/ui/Types/user";
import ChatPopupContainer from "../chat-popup/component";
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
@ -27,6 +34,10 @@ const intlMessages = defineMessages({
interface ChatListProps {
totalPages: number;
chatId: string;
currentUserId: string;
setMessageAsSeenMutation: Function;
totalUnread?: number;
lastSeenAt: number;
}
const isElement = (el: any): el is HTMLElement => {
return el instanceof HTMLElement;
@ -35,18 +46,24 @@ const isElement = (el: any): el is HTMLElement => {
const isMap = (map: any): map is Map<number, string> => {
return map instanceof Map;
}
let elHeight = 0;
const scrollObserver = new ResizeObserver((entries)=>{
const scrollObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const el = entry.target;
if (isElement(el) && isElement(el.parentElement){
el.parentElement.scrollTop = el.parentElement.scrollHeight;
if (isElement(el) && isElement(el.parentElement)) {
if (el.offsetHeight > elHeight) {
elHeight = el.offsetHeight;
el.parentElement.scrollTop = el.parentElement.scrollHeight + el.parentElement.clientHeight;
} else {
elHeight = 0;
}
}
}
});
const setLastSender = (lastSenderPerPage: Map<number, string>,) =>{
const setLastSender = (lastSenderPerPage: Map<number, string>,) => {
return (page: number, sender: string) => {
if (isMap(lastSenderPerPage)) {
lastSenderPerPage.set(page, sender);
@ -54,34 +71,121 @@ const setLastSender = (lastSenderPerPage: Map<number, string>,) =>{
}
}
const ChatList: React.FC<ChatListProps> = ({ totalPages, chatId }) => {
const lastSeenQueue = makeVar<{ [key: string]: Set<number> }>({});
const setter = makeVar<{ [key: string]: Function }>({});
const lastSeenAtVar = makeVar<{ [key: string]: number }>({});
const chatIdVar = makeVar<string>('');
const dispatchLastSeen = () => setTimeout(() => {
const lastSeenQueueValue = lastSeenQueue();
if (lastSeenQueueValue[chatIdVar()]) {
const lastTimeQueue = Array.from(lastSeenQueueValue[chatIdVar()])
const lastSeenTime = Math.max(...lastTimeQueue);
const lastSeenAtVarValue = lastSeenAtVar();
if (lastSeenTime > (lastSeenAtVarValue[chatIdVar()] ?? 0)) {
lastSeenAtVar({ ...lastSeenAtVar(), [chatIdVar()]: lastSeenTime });
setter()[chatIdVar()](lastSeenTime);
}
}
}, 500);
const ChatMessageList: React.FC<ChatListProps> = ({
totalPages,
chatId,
setMessageAsSeenMutation,
lastSeenAt,
}) => {
const intl = useIntl();
const messageListRef = React.useRef<HTMLDivElement>();
const contentRef = React.useRef<HTMLDivElement>();
// I used a ref here because I don't want to re-render the component when the last sender changes
const lastSenderPerPage = React.useRef<Map<number, string>>(new Map());
const [userLoadedBackUntilPage, setUserLoadedBackUntilPage] = React.useState<number | null>(null);
const [userLoadedBackUntilPage, setUserLoadedBackUntilPage] = useState<number | null>(null);
const [lastMessageCreatedTime, setLastMessageCreatedTime] = useState<number>(0);
const [followingTail, setFollowingTail] = React.useState(true);
useEffect(() => {
setter({
...setter(),
[chatId]: setLastMessageCreatedTime,
});
chatIdVar(chatId);
setLastMessageCreatedTime(0);
}, [chatId]);
useEffect(() => {
setMessageAsSeenMutation({
variables: {
chatId: chatId,
lastSeenAt: lastMessageCreatedTime,
},
});
}, [lastMessageCreatedTime]);
const markMessageAsSeen = useCallback((message: Message) => {
if (message.createdTime > (lastMessageCreatedTime ?? 0)) {
dispatchLastSeen();
const lastSeenQueueValue = lastSeenQueue();
if (lastSeenQueueValue[chatId]) {
lastSeenQueueValue[chatId].add(message.createdTime);
lastSeenQueue(lastSeenQueueValue)
} else {
lastSeenQueueValue[chatId] = new Set([message.createdTime]);
lastSeenQueue(lastSeenQueueValue)
}
}
}, [lastMessageCreatedTime, chatId]);
const setScrollToTailEventHandler = (el: HTMLDivElement) => {
if (Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) === 0) {
if (isElement(contentRef.current)) {
toggleFollowingTail(true)
}
} else {
if (isElement(contentRef.current)) {
toggleFollowingTail(false)
}
}
};
const toggleFollowingTail = (toggle: boolean) => {
setFollowingTail(toggle);
if (toggle) {
if (isElement(contentRef.current)) {
scrollObserver.observe(contentRef.current as HTMLDivElement);
setFollowingTail(true);
}
} else {
if (isElement(contentRef.current)) {
if (userLoadedBackUntilPage === null) {
setUserLoadedBackUntilPage(Math.max(totalPages-2, 0));
setUserLoadedBackUntilPage(Math.max(totalPages - 2, 0));
}
scrollObserver.unobserve(contentRef.current as HTMLDivElement);
setFollowingTail(false);
}
}
}
};
useEffect(() => {
if (followingTail){
const setScrollToTailEventHandler = () => {
if (scrollObserver && contentRef.current) {
scrollObserver.observe(contentRef.current as HTMLDivElement);
if (isElement(messageListRef.current)) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight + messageListRef.current.clientHeight;
}
setFollowingTail(true);
}
};
window.addEventListener(Events.SENT_MESSAGE, setScrollToTailEventHandler);
return () => {
window.removeEventListener(Events.SENT_MESSAGE, setScrollToTailEventHandler);
}
}, [contentRef.current]);
useEffect(() => {
if (followingTail) {
setUserLoadedBackUntilPage(null);
}
}, [followingTail]);
@ -95,110 +199,108 @@ const ChatList: React.FC<ChatListProps> = ({ totalPages, chatId }) => {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
}
return ()=>{
return () => {
toggleFollowingTail(false);
}
}, [contentRef]);
const firstPageToLoad = userLoadedBackUntilPage !== null
? userLoadedBackUntilPage : Math.max(totalPages-2, 0);
const pagesToLoad = (totalPages-firstPageToLoad) || 1;
? userLoadedBackUntilPage : Math.max(totalPages - 2, 0);
const pagesToLoad = (totalPages - firstPageToLoad) || 1;
return (
<MessageListWrapper>
<MessageList
ref={messageListRef}
onWheel={(e)=>{
const el = messageListRef.current as HTMLDivElement;
if (e.deltaY < 0 && el.scrollTop) {
if (isElement(contentRef.current)) {
onWheel={(e) => {
if (e.deltaY < 0) {
if (isElement(contentRef.current) && followingTail) {
toggleFollowingTail(false)
}
} else if (e.deltaY > 0) {
if (Math.abs(el.scrollHeight-el.clientHeight-el.scrollTop) === 0) {
if (isElement(contentRef.current)) {
toggleFollowingTail(true)
}
}
setScrollToTailEventHandler(messageListRef.current as HTMLDivElement);
}
}}
onMouseUp={(e)=>{
const el = messageListRef.current as HTMLDivElement;
if (Math.abs(el.scrollHeight-el.clientHeight-el.scrollTop) === 0) {
if (isElement(contentRef.current)) {
toggleFollowingTail(true)
}
} else {
if (isElement(contentRef.current)) {
toggleFollowingTail(false)
}
}
}
onMouseUp={(e) => {
setScrollToTailEventHandler(messageListRef.current as HTMLDivElement);
}}
onTouchEnd={(e) => {
setScrollToTailEventHandler(messageListRef.current as HTMLDivElement);
}}
>
<span>
{
(userLoadedBackUntilPage)
? (
<ButtonLoadMore
onClick={()=>{
if (followingTail){
toggleFollowingTail(false);
}
setUserLoadedBackUntilPage(userLoadedBackUntilPage-1);
}
}
>
{intl.formatMessage(intlMessages.loadMoreButtonLabel)}
</ButtonLoadMore>
): null
(userLoadedBackUntilPage)
? (
<ButtonLoadMore
onClick={() => {
if (followingTail) {
toggleFollowingTail(false);
}
setUserLoadedBackUntilPage(userLoadedBackUntilPage - 1);
}
}
>
{intl.formatMessage(intlMessages.loadMoreButtonLabel)}
</ButtonLoadMore>
) : null
}
</span>
<div id="contentRef" ref={contentRef}>
<ChatPopupContainer />
{
Array.from({length: pagesToLoad }, (v, k) => k+(firstPageToLoad)).map((page) => {
console.log('page', page);
Array.from({ length: pagesToLoad }, (v, k) => k + (firstPageToLoad)).map((page) => {
return (
<ChatListPage
key={`page-${page}`}
page={page}
pageSize={PAGE_SIZE}
setLastSender={setLastSender(lastSenderPerPage.current)}
// avoid the first page to have a lastSenderPreviousPage, because it doesn't exist
lastSenderPreviousPage={page ? lastSenderPerPage.current.get(page) : undefined}
lastSenderPreviousPage={page ? lastSenderPerPage.current.get(page - 1) : undefined}
chatId={chatId}
markMessageAsSeen={markMessageAsSeen}
scrollRef={messageListRef}
lastSeenAt={lastSeenAt}
/>
)
})
}
</div>
</MessageList>
</MessageListWrapper>
</MessageListWrapper >
);
}
const ChatListContainer: React.FC = ({}) => {
const ChatMessageListContainer: React.FC = ({ }) => {
const idChatOpen = layoutSelect((i) => i.idChatOpen);
const isPublicChat = idChatOpen === PUBLIC_CHAT_KEY;
const chatId = !isPublicChat ? idChatOpen : PUBLIC_GROUP_CHAT_KEY;
const {
data: chatData,
loading: chatLoading,
error: chatError,
} = useSubscription<ChatSubscriptionResponse>(CHAT_SUBSCRIPTION);
// We verify if the chat is loading to avoid fetching uneccessary messages
// and we use MessageListWrapper to fill the space in interface while loading.
if (chatLoading) return <MessageListWrapper />;
if (chatError) return <p>chatError: {chatError}</p>;
const currentChat = chatData?.chat?.find((chat) => chat?.chatId === chatId);
const currentChat = useChat((chat) => {
return {
chatId: chat.chatId,
totalMessages: chat.totalMessages,
totalUnread: chat.totalUnread,
lastSeenAt: chat.lastSeenAt,
}
}, chatId) as Partial<Chat>;
const currentUser = useCurrentUser((user: Partial<User>) => {
return {
userId: user.userId,
}
});
const [setMessageAsSeenMutation] = useMutation(LAST_SEEN_MUTATION);
const totalMessages = currentChat?.totalMessages || 0;
const totalPages = Math.ceil(totalMessages / PAGE_SIZE);
return (
<ChatList
<ChatMessageList
lastSeenAt={currentChat?.lastSeenAt || 0}
totalPages={totalPages}
chatId={chatId}
currentUserId={currentUser?.userId ?? ''}
setMessageAsSeenMutation={setMessageAsSeenMutation}
totalUnread={currentChat?.totalUnread || 0}
/>
);
}
export default ChatListContainer;
export default ChatMessageListContainer;

View File

@ -1,80 +1,166 @@
import React, { Ref, useEffect } from "react";
import React, { Ref, useCallback, useEffect, useMemo, useRef } from "react";
import { Message } from '/imports/ui/Types/message';
import { FormattedTime, defineMessages, useIntl } from 'react-intl';
import {
ChatAvatar,
ChatTime,
ChatUserContent,
ChatUserName,
ChatWrapper,
ChatUserOffline,
ChatMessage,
ChatContent,
} from "./styles";
import ChatMessageHeader from "./message-header/component";
import ChatMessageTextContent from "./message-content/text-content/component";
import ChatPollContent from "./message-content/poll-content/component";
import ChatMessagePresentationContent from "./message-content/presentation-content/component";
import { defineMessages, useIntl } from "react-intl";
interface ChatMessageProps {
message: Message;
previousMessage?: Message;
lastSenderPreviousPage?: string | null;
scrollRef: React.RefObject<HTMLDivElement>;
markMessageAsSeen: Function;
}
const enum MessageType {
TEXT = 'default',
POLL = 'poll',
PRESENTATION = 'presentation',
CHAT_CLEAR = 'publicChatHistoryCleared'
}
const intlMessages = defineMessages({
offline: {
id: 'app.chat.offline',
description: 'Offline',
pollResult: {
id: 'app.chat.pollResult',
description: 'used in place of user name who published poll to chat',
},
presentationLabel: {
id: 'app.presentationUploder.title',
description: 'presentation area element label',
},
systemLabel: {
id: 'app.toast.chat.system',
description: 'presentation area element label',
},
chatClear: {
id: 'app.chat.clearPublicChatMessage',
description: 'message of when clear the public chat',
},
});
const ChatMesssage: React.FC<ChatMessageProps> = ({ message, previousMessage, lastSenderPreviousPage}) => {
function isInViewport(el: HTMLDivElement) {
const rect = el.getBoundingClientRect();
return (
rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.bottom >= 0
);
}
const ChatMesssage: React.FC<ChatMessageProps> = ({
message,
previousMessage,
lastSenderPreviousPage,
scrollRef,
markMessageAsSeen,
}) => {
const intl = useIntl();
const messageRef = useRef<HTMLDivElement>(null);
const markMessageAsSeenOnScrollEnd = useCallback((message, messageRef) => {
if (messageRef.current && isInViewport(messageRef.current)) {
markMessageAsSeen(message);
}
}, []);
useEffect(() => {
// I use a function here to remove the event listener using the same reference
const callbackFunction = () => {
markMessageAsSeenOnScrollEnd(message, messageRef);
}
if (message && scrollRef.current && messageRef.current) {
if (isInViewport(messageRef.current)) {
markMessageAsSeen(message);
} else {
scrollRef.current.addEventListener('scrollend', callbackFunction);
}
}
return () => {
scrollRef?.current
?.removeEventListener('scrollend', callbackFunction);
}
}, [message, messageRef]);
if (!message) return null;
const sameSender = (previousMessage?.user?.userId || lastSenderPreviousPage) === message?.user?.userId;
const dateTime = new Date(message?.createdTime);
const messageContent: {
name: string,
color: string,
isModerator: boolean,
component: React.ReactElement,
} = useMemo(() => {
switch (message.messageType) {
case MessageType.POLL:
return {
name: intl.formatMessage(intlMessages.pollResult),
color: '#3B48A9',
isModerator: true,
component: (
<ChatPollContent metadata={message.messageMetadata} />
),
};
case MessageType.PRESENTATION:
return {
name: intl.formatMessage(intlMessages.presentationLabel),
color: '#0F70D7',
isModerator: true,
component: (
<ChatMessagePresentationContent metadata={message.messageMetadata} />
),
};
case MessageType.CHAT_CLEAR:
return {
name: intl.formatMessage(intlMessages.systemLabel),
color: '#0F70D7',
isModerator: true,
component: (
<ChatMessageTextContent
emphasizedMessage={true}
text={intl.formatMessage(intlMessages.chatClear)}
/>
),
};
case MessageType.TEXT:
default:
return {
name: message.user?.name,
color: message.user?.color,
isModerator: message.user?.isModerator,
component: (
<ChatMessageTextContent
emphasizedMessage={message?.user?.isModerator}
text={message.message}
/>
),
}
}
}, []);
return (
<ChatWrapper
sameSender={sameSender}
ref={messageRef}
>
{
sameSender ? null : (
<ChatAvatar
avatar={message.user?.avatar}
color={message.user?.color}
moderator={message.user?.isModerator}
>
{message.user?.name.toLowerCase().slice(0, 2) || " "}
</ChatAvatar>
)
}
<ChatMessageHeader
sameSender={message?.user ? sameSender : false}
name={messageContent.name}
color={messageContent.color}
isModerator={messageContent.isModerator}
isOnline={message.user?.isOnline ?? true}
avatar={message.user?.avatar}
dateTime={dateTime}
/>
<ChatContent>
{sameSender ? null :
(
<ChatUserContent>
<ChatUserName
>
{message.user?.name}
</ChatUserName>
{
message.user?.isOnline ? null : (
<ChatUserOffline
>
{`(${intl.formatMessage(intlMessages.offline)})`}
</ChatUserOffline>
)
}
<ChatTime>
<FormattedTime value={dateTime} />
</ChatTime>
</ChatUserContent>
)
}
<ChatMessage
sameSender={sameSender}
emphasizedMessage={message.user?.isModerator}
>
{message.message}
</ChatMessage>
{
messageContent.component
}
</ChatContent>
</ChatWrapper>
);

View File

@ -0,0 +1,77 @@
import React from "react";
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts";
import Styled from './styles';
interface ChatPollContentProps {
metadata: string;
}
interface Metadata {
id: string;
question: string;
numRespondents: number;
numResponders: number;
questionText: string;
questionType: string;
answers: Array<Answers>;
}
interface Answers {
key: string;
numVotes: number;
id: number;
}
function assertAsMetadata(metadata: unknown): asserts metadata is Metadata {
if (typeof metadata !== 'object' || metadata === null) {
throw new Error('metadata is not an object');
}
if (typeof (metadata as Metadata).id !== 'string') {
throw new Error('metadata.id is not a string');
}
if (typeof (metadata as Metadata).numRespondents !== 'number') {
throw new Error('metadata.numRespondents is not a number');
}
if (typeof (metadata as Metadata).numResponders !== 'number') {
throw new Error('metadata.numResponders is not a number');
}
if (typeof (metadata as Metadata).questionText !== 'string') {
throw new Error('metadata.questionText is not a string');
}
if (typeof (metadata as Metadata).questionType !== 'string') {
throw new Error('metadata.questionType is not a string');
}
if (!Array.isArray((metadata as Metadata).answers)) {
throw new Error('metadata.answers is not an array');
}
if ((metadata as Metadata).answers.length === 0) {
throw new Error('metadata.answers is empty');
}
}
const ChatPollContent: React.FC<ChatPollContentProps> = ({
metadata: string,
}) => {
const pollData = JSON.parse(string) as unknown;
assertAsMetadata(pollData);
return (
<div>
<Styled.pollText>
{pollData.questionText}
</Styled.pollText>
<ResponsiveContainer width="100%" height={250}>
<BarChart
data={pollData.answers}
layout="vertical"
>
<XAxis type="number" />
<YAxis type="category" dataKey="key" />
<Bar dataKey="numVotes" fill="#0C57A7" />
</BarChart>
</ResponsiveContainer>
</div>
);
};
export default ChatPollContent;

View File

@ -0,0 +1,16 @@
import styled from 'styled-components';
import { colorText } from '/imports/ui/stylesheets/styled-components/palette';
export const pollText = styled.div`
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-size: 1.25rem;
font-weight: 500;
margin-left: 2.75rem;
color: ${colorText};
word-break: break-word;
`;
export default {
pollText,
};

View File

@ -0,0 +1,58 @@
import React from "react";
import Styled from './styles';
import { defineMessages, useIntl } from "react-intl";
interface ChatMessagePresentationContentProps {
metadata: string;
}
interface Metadata {
fileURI: string;
filename: string;
}
function assertAsMetadata(metadata: unknown): asserts metadata is Metadata {
if (typeof metadata !== 'object' || metadata === null) {
throw new Error('metadata is not an object');
}
if (typeof (metadata as Metadata).fileURI !== 'string') {
throw new Error('metadata.fileURI is not a string');
}
if (typeof (metadata as Metadata).filename !== 'string') {
throw new Error('metadata.fileName is not a string');
}
}
const intlMessages = defineMessages({
download: {
id: 'app.presentation.downloadLabel',
description: 'used as label for presentation download link',
},
notAccessibleWarning: {
id: 'app.presentationUploader.export.notAccessibleWarning',
description: 'used for indicating that a link may be not accessible',
},
});
const ChatMessagePresentationContent: React.FC<ChatMessagePresentationContentProps> = ({
metadata: string,
}) => {
const intl = useIntl();
const presentationData = JSON.parse(string) as unknown;
assertAsMetadata(presentationData);
return (
<Styled.ChatDowloadContainer>
<span>{presentationData.filename}</span>
<Styled.ChatLink
href={presentationData.fileURI}
aria-label={intl.formatMessage(intlMessages.notAccessibleWarning)}
type="application/pdf"
rel="noopener, noreferrer"
download
>
{intl.formatMessage(intlMessages.download)} <i className="icon-bbb-warning"></i>
</Styled.ChatLink>
</Styled.ChatDowloadContainer >
);
}
export default ChatMessagePresentationContent;

View File

@ -0,0 +1,19 @@
import styled from 'styled-components';
import { colorText, colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
export const ChatDowloadContainer = styled.div`
display: flex;
flex-flow: column;
color: ${colorText};
word-break: break-word;
margin-left: 2.75rem;
`;
export const ChatLink = styled.a`
color: ${colorPrimary};
`;
export default {
ChatDowloadContainer,
ChatLink,
};

View File

@ -0,0 +1,19 @@
import React from "react";
import Styled from './styles';
interface ChatMessageTextContentProps {
text: string;
emphasizedMessage: boolean;
}
const ChatMessageTextContent: React.FC<ChatMessageTextContentProps> = ({
text,
emphasizedMessage,
}) => {
return (
<Styled.ChatMessage emphasizedMessage={emphasizedMessage}>
{text}
</Styled.ChatMessage>
);
};
export default ChatMessageTextContent;

View File

@ -0,0 +1,20 @@
import styled from 'styled-components';
import { colorText } from '/imports/ui/stylesheets/styled-components/palette';
export const ChatMessage = styled.div`
flex: 1;
display: flex;
flex-flow: row;
color: ${colorText};
word-break: break-word;
margin-left: 2.75rem;
${({ emphasizedMessage }) =>
emphasizedMessage &&
`
font-weight: bold;
`}
`;
export default {
ChatMessage,
};

View File

@ -0,0 +1,65 @@
import React from "react";
import Styled from './styles';
import { useIntl, defineMessages, FormattedTime } from "react-intl";
const intlMessages = defineMessages({
offline: {
id: 'app.chat.offline',
description: 'Offline',
},
});
interface ChatMessageHeaderProps {
name: string;
avatar: string;
color: string;
isModerator: boolean;
isOnline: boolean;
dateTime: Date;
sameSender: boolean;
}
const ChatMessageHeader: React.FC<ChatMessageHeaderProps> = ({
sameSender,
name,
color,
isModerator,
avatar,
isOnline,
dateTime,
}) => {
const intl = useIntl();
if (sameSender) return null;
return (
<Styled.HeaderContent>
<Styled.ChatAvatar
avatar={avatar}
color={color}
moderator={isModerator}
>
{name.toLowerCase().slice(0, 2) || " "}
</Styled.ChatAvatar>
<Styled.ChatHeaderText>
<Styled.ChatUserName>
{name}
</Styled.ChatUserName>
{
isOnline ? null : (
<Styled.ChatUserOffline
>
{`(${intl.formatMessage(intlMessages.offline)})`}
</Styled.ChatUserOffline>
)
}
<Styled.ChatTime>
<FormattedTime value={dateTime} />
</Styled.ChatTime>
</Styled.ChatHeaderText>
</Styled.HeaderContent>
)
};
export default ChatMessageHeader;

View File

@ -0,0 +1,190 @@
import styled, { css } from 'styled-components';
import {
borderSize,
userIndicatorsOffset,
} from '/imports/ui/stylesheets/styled-components/general';
import {
colorWhite,
userListBg,
colorSuccess,
colorHeading,
palettePlaceholderText,
colorGrayLight,
colorText,
} from '/imports/ui/stylesheets/styled-components/palette';
import {
lineHeightComputed,
fontSizeBase,
btnFontWeight,
} from '/imports/ui/stylesheets/styled-components/typography';
export const HeaderContent = styled.div`
display: flex;
flex-flow: row;
width: 100%;
`;
export const ChatUserName = styled.div`
display: flex;
min-width: 0;
font-weight: 600;
position: relative;
min-width: 0;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
${({ isOnline }) =>
isOnline &&
`
color: ${colorHeading};
`}
${({ isOnline }) =>
!isOnline &&
`
text-transform: capitalize;
font-style: italic;
& > span {
text-align: right;
padding: 0 .1rem 0 0;
[dir="rtl"] & {
text-align: left;
padding: 0 0 0 .1rem;
}
}
`}
`;
export const ChatUserOffline = styled.span`
color: ${colorGrayLight};
font-weight: 100;
text-transform: lowercase;
font-style: italic;
font-size: 90%;
line-height: 1;
user-select: none;
margin: 0 0 0 calc(${lineHeightComputed} / 2);
`;
export const ChatTime = styled.time`
flex-shrink: 0;
flex-grow: 0;
flex-basis: 3.5rem;
color: ${palettePlaceholderText};
text-transform: uppercase;
font-size: 75%;
margin: 0 0 0 calc(${lineHeightComputed} / 2);
[dir='rtl'] & {
margin: 0 calc(${lineHeightComputed} / 2) 0 0;
}
& > span {
vertical-align: sub;
}
`;
export const ChatAvatar = styled.div`
flex: 0 0 2.25rem;
margin: 0px calc(0.5rem) 0px 0px;
box-flex: 0;
position: relative;
height: 2.25rem;
width: 2.25rem;
border-radius: 50%;
text-align: center;
font-size: .85rem;
border: 2px solid transparent;
user-select: none;
${({ color }) => css`
background-color: ${color};
`}
}
&:after,
&:before {
content: "";
position: absolute;
width: 0;
height: 0;
padding-top: .5rem;
padding-right: 0;
padding-left: 0;
padding-bottom: 0;
color: inherit;
top: auto;
left: auto;
bottom: ${userIndicatorsOffset};
right: ${userIndicatorsOffset};
border: 1.5px solid ${userListBg};
border-radius: 50%;
background-color: ${colorSuccess};
color: ${colorWhite};
opacity: 0;
font-family: 'bbb-icons';
font-size: .65rem;
line-height: 0;
text-align: center;
vertical-align: middle;
letter-spacing: -.65rem;
z-index: 1;
[dir="rtl"] & {
left: ${userIndicatorsOffset};
right: auto;
padding-right: .65rem;
padding-left: 0;
}
}
${({ moderator }) =>
moderator &&
`
border-radius: 5px;
`}
// ================ image ================
${({ avatar, emoji }) =>
avatar?.length !== 0 &&
!emoji &&
css`
background-image: url(${avatar});
background-repeat: no-repeat;
background-size: contain;
`}
// ================ image ================
// ================ content ================
color: ${colorWhite};
font-size: 110%;
text-transform: capitalize;
display: flex;
justify-content: center;
align-items:center;
// ================ content ================
& .react-loading-skeleton {
height: 2.25rem;
width: 2.25rem;
}
`;
export const ChatHeaderText = styled.div`
display: flex;
align-items: baseline;
width: 100%;
`;
export default {
HeaderContent,
ChatAvatar,
ChatTime,
ChatUserOffline,
ChatUserName,
ChatHeaderText,
};

View File

@ -11,7 +11,7 @@ import {
colorHeading,
palettePlaceholderText,
colorGrayLight,
colorText
colorText,
} from '/imports/ui/stylesheets/styled-components/palette';
import {
lineHeightComputed,
@ -20,20 +20,23 @@ import {
} from '/imports/ui/stylesheets/styled-components/typography';
export const ChatWrapper = styled.div`
pointer-events: auto;
[dir="rtl"] & {
[dir='rtl'] & {
direction: rtl;
}
display: flex;
flex-flow: row;
flex-flow: column;
position: relative;
${({ sameSender }) => sameSender && `
${({ sameSender }) =>
sameSender &&
`
flex: 1;
margin: ${borderSize} 0 0 ${borderSize};
margin-top: calc(${lineHeightComputed} / 3);
`}
${({ sameSender }) => !sameSender && `
${({ sameSender }) =>
!sameSender &&
`
padding-top:${lineHeightComputed};
`}
[dir="rtl"] & {
@ -42,174 +45,8 @@ export const ChatWrapper = styled.div`
font-size: ${fontSizeBase};
`;
export const ChatUserContent = styled.div`
display: flex;
flex-flow: row;
width: 100%;
`;
export const ChatUserName = styled.div`
display: flex;
min-width: 0;
font-weight: 600;
position: relative;
min-width: 0;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
${({ isOnline }) => isOnline && `
color: ${colorHeading};
`}
${({ isOnline }) => !isOnline && `
text-transform: capitalize;
font-style: italic;
& > span {
text-align: right;
padding: 0 .1rem 0 0;
[dir="rtl"] & {
text-align: left;
padding: 0 0 0 .1rem;
}
}
`}
`;
export const ChatUserOffline = styled.span`
color: ${colorGrayLight};
font-weight: 100;
text-transform: lowercase;
font-style: italic;
font-size: 90%;
line-height: 1;
align-self: center;
user-select: none;
margin: 0 0 0 calc(${lineHeightComputed} / 2);
`;
export const ChatTime = styled.time`
flex-shrink: 0;
flex-grow: 0;
flex-basis: 3.5rem;
color: ${palettePlaceholderText};
text-transform: uppercase;
font-size: 75%;
margin: 0 0 0 calc(${lineHeightComputed} / 2);
align-self: center;
[dir="rtl"] & {
margin: 0 calc(${lineHeightComputed} / 2) 0 0;
}
& > span {
vertical-align: sub;
}
`;
export const ChatContent = styled.div`
display: flex;
flex-flow: column;
width: 100%;
`
export const ChatMessage = styled.div`
flex: 1;
display: flex;
flex-flow: row;
color: ${colorText};
word-break: break-word;
${({ sameSender }) => sameSender && `
margin-left: calc(2.75rem - 2px);
`}
${({ emphasizedMessage }) => emphasizedMessage && `
font-weight: bold;
`}
`;
export const ChatAvatar = styled.div`
flex: 0 0 2.25rem;
margin: 0px calc(0.5rem) 0px 0px;
box-flex: 0;
position: relative;
height: 2.25rem;
width: 2.25rem;
border-radius: 50%;
text-align: center;
font-size: .85rem;
border: 2px solid transparent;
user-select: none;
${
({ color }) => css`
background-color: ${color};
`}
}
&:after,
&:before {
content: "";
position: absolute;
width: 0;
height: 0;
padding-top: .5rem;
padding-right: 0;
padding-left: 0;
padding-bottom: 0;
color: inherit;
top: auto;
left: auto;
bottom: ${userIndicatorsOffset};
right: ${userIndicatorsOffset};
border: 1.5px solid ${userListBg};
border-radius: 50%;
background-color: ${colorSuccess};
color: ${colorWhite};
opacity: 0;
font-family: 'bbb-icons';
font-size: .65rem;
line-height: 0;
text-align: center;
vertical-align: middle;
letter-spacing: -.65rem;
z-index: 1;
[dir="rtl"] & {
left: ${userIndicatorsOffset};
right: auto;
padding-right: .65rem;
padding-left: 0;
}
}
${({ moderator }) => moderator && `
border-radius: 5px;
`}
// ================ image ================
${({ avatar, emoji }) => avatar?.length !== 0 && !emoji && css`
background-image: url(${avatar});
background-repeat: no-repeat;
background-size: contain;
`}
// ================ image ================
// ================ content ================
color: ${colorWhite};
font-size: 110%;
text-transform: capitalize;
display: flex;
justify-content: center;
align-items:center;
// ================ content ================
& .react-loading-skeleton {
height: 2.25rem;
width: 2.25rem;
}
`;

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import { useSubscription } from "@apollo/client";
import { Meteor } from 'meteor/meteor';
import {
@ -19,12 +19,16 @@ interface ChatListPageContainerProps {
setLastSender: Function;
lastSenderPreviousPage: string | undefined;
chatId: string;
markMessageAsSeen: Function;
scrollRef: React.RefObject<HTMLDivElement>;
}
interface ChatListPageProps {
messages: Array<Message>;
lastSenderPreviousPage: string | undefined;
page: number;
markMessageAsSeen: Function;
scrollRef: React.RefObject<HTMLDivElement>;
}
const verifyIfIsPublicChat = (message: unknown): message is ChatMessagePublicSubscriptionResponse => {
@ -35,20 +39,27 @@ const verifyIfIsPrivateChat = (message: unknown): message is ChatMessagePrivateS
return (message as ChatMessagePrivateSubscriptionResponse).chat_message_private !== undefined;
}
const ChatListPage: React.FC<ChatListPageProps> = ({
messages,
lastSenderPreviousPage,
page,
markMessageAsSeen,
scrollRef,
const ChatListPage: React.FC<ChatListPageProps> = ({ messages, lastSenderPreviousPage, page }) => {
}) => {
return (
<div id={`messagePage-${page}`}>
<div key={`messagePage-${page}`} id={`${page}`} >
{
messages.map((message, index, Array) => {
const previousMessage = Array[index-1];
const previousMessage = Array[index - 1];
return (
<ChatMessage
key={message.createdTime}
message={message}
previousMessage={previousMessage}
lastSenderPreviousPage={!previousMessage ? lastSenderPreviousPage : null}
scrollRef={scrollRef}
markMessageAsSeen={markMessageAsSeen}
/>
)
})
@ -63,14 +74,16 @@ const ChatListPageContainer: React.FC<ChatListPageContainerProps> = ({
setLastSender,
lastSenderPreviousPage,
chatId,
markMessageAsSeen,
scrollRef,
}) => {
const isPublicChat = chatId === PUBLIC_GROUP_CHAT_KEY;
const chatQuery = isPublicChat
? CHAT_MESSAGE_PUBLIC_SUBSCRIPTION
: CHAT_MESSAGE_PRIVATE_SUBSCRIPTION;
const defaultVariables = { offset: (page)*pageSize, limit: pageSize };
const defaultVariables = { offset: (page) * pageSize, limit: pageSize };
const variables = isPublicChat ? defaultVariables : { ...defaultVariables, requestedChatId: chatId };
const {
const {
data: chatMessageData,
loading: chatMessageLoading,
error: chatMessageError,
@ -89,7 +102,8 @@ const ChatListPageContainer: React.FC<ChatListPageContainerProps> = ({
}
if (messages.length > 0) {
setLastSender(page, messages[messages.length-1].user?.userId);
setLastSender(page, messages[messages.length - 1].user?.userId);
}
return (
@ -97,6 +111,8 @@ const ChatListPageContainer: React.FC<ChatListPageContainerProps> = ({
messages={messages}
lastSenderPreviousPage={lastSenderPreviousPage}
page={page}
markMessageAsSeen={markMessageAsSeen}
scrollRef={scrollRef}
/>
);
}

View File

@ -10,40 +10,51 @@ export interface ChatMessagePrivateSubscriptionResponse {
chat_message_private: Array<Message>;
}
export const CHAT_MESSAGE_PUBLIC_SUBSCRIPTION = gql`subscription chatMessages($limit: Int!, $offset: Int!) {
chat_message_public(limit: $limit, offset: $offset, order_by: {createdTime: asc}) {
user {
name
userId
avatar
isOnline
isModerator
color
export const CHAT_MESSAGE_PUBLIC_SUBSCRIPTION = gql`
subscription chatMessages($limit: Int!, $offset: Int!) {
chat_message_public(limit: $limit, offset: $offset, order_by: { createdTime: asc }) {
user {
name
userId
avatar
isOnline
isModerator
color
}
messageType
chatId
message
messageId
createdTime
createdTimeAsDate
messageMetadata
}
message
messageId
createdTime
createdTimeAsDate
}
}
`;
export const CHAT_MESSAGE_PRIVATE_SUBSCRIPTION = gql`subscription chatMessages($limit: Int!, $offset: Int!, $requestedChatId: String!) {
chat_message_private(limit: $limit, offset: $offset, where: {chatId: {_eq: $requestedChatId }} , order_by: {createdTime: asc}) {
user {
name
userId
avatar
isOnline
isModerator
color
export const CHAT_MESSAGE_PRIVATE_SUBSCRIPTION = gql`
subscription chatMessages($limit: Int!, $offset: Int!, $requestedChatId: String!) {
chat_message_private(
limit: $limit
offset: $offset
where: { chatId: { _eq: $requestedChatId } }
order_by: { createdTime: asc }
) {
user {
name
userId
avatar
isOnline
isModerator
color
}
chatId
message
messageType
messageId
createdTime
createdTimeAsDate
messageMetadata
}
message
messageId
createdTime
createdTimeAsDate
}
}
`;

View File

@ -1,23 +1,12 @@
import { gql } from '@apollo/client';
import { Chat } from '/imports/ui/Types/chat';
export interface ChatSubscriptionResponse {
chat: Array<Chat>;
}
export const CHAT_SUBSCRIPTION = gql`
subscription {
chat {
chatId
participant {
name
role
color
loggedOut
}
totalMessages
totalUnread
public
}
}
`
export const LAST_SEEN_MUTATION = gql`
mutation UpdateChatUser($chatId: String, $lastSeenAt: bigint) {
update_chat_user(
where: { chatId: { _eq: $chatId }, lastSeenAt: { _lt: $lastSeenAt } }
_set: { lastSeenAt: $lastSeenAt }
) {
affected_rows
}
}
`;

View File

@ -9,9 +9,9 @@ import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scr
import { colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette';
export const MessageListWrapper = styled.div`
height: 100%;
display: flex;
flex-flow: column;
flex-grow: 1;
flex-shrink: 1;
position: relative;
overflow-x: hidden;
@ -22,7 +22,7 @@ export const MessageListWrapper = styled.div`
margin-right: calc(-1 * ${mdPaddingY});
padding-bottom: ${mdPaddingX};
z-index: 2;
[dir="rtl"] & {
[dir='rtl'] & {
padding-right: ${mdPaddingX};
margin-right: calc(-1 * ${mdPaddingX});
padding-left: ${mdPaddingY};
@ -33,30 +33,28 @@ export const MessageListWrapper = styled.div`
export const MessageList = styled(ScrollboxVertical)`
flex-flow: column;
flex-shrink: 1;
margin: 0 auto 0 0;
right: 0 ${mdPaddingX} 0 0;
padding-top: 0;
width: 100%;
outline-style: none;
overflow-x: hidden;
[dir="rtl"] & {
[dir='rtl'] & {
margin: 0 0 0 auto;
padding: 0 0 0 ${mdPaddingX};
}
display:block;
display: block;
`;
export const ButtonLoadMore = styled.button`
width: 100%;
min-height: 1.5rem;
margin-bottom: .75rem;
margin-bottom: 0.75rem;
background-color: transparent;
border-radius: ${borderRadius};
border: 1px ridge ${colorGrayDark}
border: 1px ridge ${colorGrayDark};
`;
export default {
MessageListWrapper,
MessageList,
}
};

View File

@ -1,56 +1,106 @@
import React, { useEffect } from 'react';
import { PopupContainer } from './styles';
import { PopupContainer, PopupContents } from './styles';
import { GET_WELCOME_MESSAGE, WelcomeMsgsResponse } from './queries';
import { useQuery } from '@apollo/client';
import PopupContent from './popup-content/component';
import Events from '/imports/ui/core/events/events';
import { layoutSelect } from '../../../layout/context';
import { Layout } from '../../../layout/layoutTypes';
interface ChatPopupProps {
welcomeMessage?: string | null;
welcomeMsgForModerators?: string | null;
}
const WELCOME_MSG_KEY = 'welcomeMsg';
const WELCOME_MSG_FOR_MODERATORS_KEY = 'welcomeMsgForModerators';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
const setWelcomeMsgsOnSession = (key: string, value: boolean) => {
sessionStorage.setItem(key, String(value));
};
const isBoolean = (v: any): boolean => {
if (v === 'true') {
return true;
} else if (v === 'false') {
return false;
}
// if v is not difined it shouldn't be considered on comparation, so it returns true
return true;
}
const ChatPopup: React.FC<ChatPopupProps> = ({
welcomeMessage,
welcomeMsgForModerators,
}) => {
const [showWelcomeMessage, setShowWelcomeMessage] = React.useState(false);
const [showWelcomeMessageForModerators, setShowWelcomeMessageForModerators] = React.useState(false);
const [showWelcomeMessage, setShowWelcomeMessage] = React.useState(
welcomeMessage && isBoolean(sessionStorage.getItem(WELCOME_MSG_KEY))
);
const [showWelcomeMessageForModerators, setShowWelcomeMessageForModerators] = React.useState(
welcomeMsgForModerators && isBoolean(sessionStorage.getItem(WELCOME_MSG_FOR_MODERATORS_KEY))
);
useEffect(() => {
if (welcomeMessage) {
setShowWelcomeMessage(true);
}
if (welcomeMsgForModerators) {
setShowWelcomeMessageForModerators(true);
const eventCallback = () => {
if (welcomeMessage) {
setShowWelcomeMessage(true);
setWelcomeMsgsOnSession(WELCOME_MSG_KEY, true);
}
if (welcomeMsgForModerators) {
setShowWelcomeMessageForModerators(true);
setWelcomeMsgsOnSession(WELCOME_MSG_FOR_MODERATORS_KEY, true);
}
};
window.addEventListener(Events.RESTORE_WELCOME_MESSAGES, eventCallback);
return () => {
removeEventListener(Events.RESTORE_WELCOME_MESSAGES, eventCallback);
}
}, []);
if (!showWelcomeMessage && !showWelcomeMessageForModerators) return null;
return (
<PopupContainer>
{showWelcomeMessage && welcomeMessage && (
<PopupContent
message={welcomeMessage}
closePopup={()=> setShowWelcomeMessage(false)}
/>
)}
{showWelcomeMessageForModerators && welcomeMsgForModerators && (
<PopupContent
message={welcomeMsgForModerators}
closePopup={()=> setShowWelcomeMessageForModerators(false)}
<PopupContents>
{showWelcomeMessage && welcomeMessage && (
<PopupContent
message={welcomeMessage}
closePopup={() => {
setShowWelcomeMessage(false);
setWelcomeMsgsOnSession(WELCOME_MSG_KEY, false);
}}
/>
)}
)}
{showWelcomeMessageForModerators && welcomeMsgForModerators && (
<PopupContent
message={welcomeMsgForModerators}
closePopup={() => {
setShowWelcomeMessageForModerators(false);
setWelcomeMsgsOnSession(WELCOME_MSG_FOR_MODERATORS_KEY, false);
}}
/>
)}
</PopupContents>
</PopupContainer>
);
};
const ChatPopupContainer: React.FC = () => {
const {
data: welcomeData,
loading: welcomeLoading,
error: welcomeError,
} = useQuery<WelcomeMsgsResponse>(GET_WELCOME_MESSAGE);
if(welcomeLoading) return null;
if(welcomeError) return <div>{JSON.stringify(welcomeError)}</div>;
if(!welcomeData) return null;
data: welcomeData,
loading: welcomeLoading,
error: welcomeError,
} = useQuery<WelcomeMsgsResponse>(GET_WELCOME_MESSAGE);
const idChatOpen = layoutSelect((i: Layout) => i.idChatOpen);
if (idChatOpen !== PUBLIC_GROUP_CHAT_KEY) return null;
if (welcomeLoading) return null;
if (welcomeError) return <div>{JSON.stringify(welcomeError)}</div>;
if (!welcomeData) return null;
return (
<ChatPopup

View File

@ -1,15 +1,13 @@
import styled from "styled-components";
import styled from 'styled-components';
import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
export const PopupContainer = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
height: 100%;
max-height: 40%;
z-index: 10;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
position: sticky;
top: 0;
max-height: 80%;
z-index: 3;
`;
export const PopupContents = styled(ScrollboxVertical)`
height: 100%;
`;

View File

@ -4,22 +4,25 @@ import { useSubscription } from '@apollo/client';
import {
IS_TYPING_PUBLIC_SUBSCRIPTION,
IS_TYPING_PRIVATE_SUBSCRIPTION,
} from '../queries';
} from './queries';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { User } from '/imports/ui/Types/user';
import Styled from './styles';
import { useCurrentUser } from '/imports/ui/core/hooks/useCurrentUser';
import { layoutSelect } from '../../../layout/context';
import { Layout } from '../../../layout/layoutTypes';
import useChat from '/imports/ui/core/hooks/useChat';
import { Chat } from '/imports/ui/Types/chat';
const DEBUG_CONSOLE = false;
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
const TYPING_INDICATOR_ENABLED = CHAT_CONFIG.typingIndicator.enabled;
interface TypingIndicatorProps {
typingUsers: Array<User>,
indicatorEnabled: boolean,
error: string,
}
interface TypingIndicatorContainerProps {
userId: string,
isTypingTo: string,
isPrivate: boolean,
error: string,
}
const messages = defineMessages({
@ -29,13 +32,11 @@ const messages = defineMessages({
},
});
const CHAT_CONFIG = Meteor.settings.public.chat;
const TYPING_INDICATOR_ENABLED = CHAT_CONFIG.typingIndicator.enabled;
const TypingIndicator: React.FC<TypingIndicatorProps> = ({
typingUsers,
indicatorEnabled,
error,
indicatorEnabled.
}) => {
const intl = useIntl();
@ -98,37 +99,62 @@ const TypingIndicator: React.FC<TypingIndicatorProps> = ({
return (
<Styled.TypingIndicatorWrapper
error={!!error}
info={!error}
spacer={!!element}
>
<Styled.TypingIndicator data-test="typingIndicator">{error || element}</Styled.TypingIndicator>
<Styled.TypingIndicator data-test="typingIndicator">{element}</Styled.TypingIndicator>
</Styled.TypingIndicatorWrapper>
);
};
const TypingIndicatorContainer: React.FC<TypingIndicatorContainerProps> = ({ userId, isTypingTo, isPrivate, error }) => {
const {
data: typingUsersData,
} = useSubscription(isPrivate ? IS_TYPING_PRIVATE_SUBSCRIPTION : IS_TYPING_PUBLIC_SUBSCRIPTION, {
variables: {
chatId: isTypingTo,
const TypingIndicatorContainer: React.FC = () => {
const idChatOpen: string = layoutSelect((i: Layout) => i.idChatOpen);
const currentUser = useCurrentUser((user: Partial<User>) => {
return {
userId: user.userId,
}
});
DEBUG_CONSOLE && console.log('TypingIndicatorContainer:currentUser', currentUser);
const chat = useChat((c: Partial<Chat>) => {
const participant = c?.participant ? {
participant: {
name: c?.participant?.name,
isModerator: c?.participant?.isModerator,
isOnline: c?.participant?.isOnline,
}
} : {};
return {
...participant,
chatId: c?.chatId,
public: c?.public,
};
}, idChatOpen) as Partial<Chat>;
DEBUG_CONSOLE && console.log('TypingIndicatorContainer:chat', chat);
const typingQuery = idChatOpen === PUBLIC_GROUP_CHAT_KEY ? IS_TYPING_PUBLIC_SUBSCRIPTION : IS_TYPING_PRIVATE_SUBSCRIPTION;
const {
data: typingUsersData,
error: typingUsersError,
} = useSubscription(typingQuery, {
variables: {
chatId: idChatOpen,
}
});
DEBUG_CONSOLE && console.log('TypingIndicatorContainer:typingUsersData', typingUsersData);
if (typingUsersError) return <div>Error: {JSON.stringify(typingUsersError)}</div>
const publicTypingUsers = typingUsersData?.user_typing_public || [];
const privateTypingUsers = typingUsersData?.user_typing_private || [];
const typingUsers = privateTypingUsers.concat(publicTypingUsers);
const typingUsersArray = typingUsers
.filter((user: { user: object; userId: string; }) => user?.user && user?.userId !== userId)
.filter((user: { user: object; userId: string; }) => user?.user && user?.userId !== currentUser?.userId)
.map((user: { user: object; }) => user.user);
return <TypingIndicator
typingUsers={typingUsersArray}
indicatorEnabled={TYPING_INDICATOR_ENABLED}
error={error}
/>
};

View File

@ -8,7 +8,6 @@ export const IS_TYPING_PUBLIC_SUBSCRIPTION = gql`subscription IsTyping($chatId:
chatId: {_eq: $chatId}
}
) {
meetingId
chatId
userId
typingAt
@ -27,7 +26,6 @@ export const IS_TYPING_PRIVATE_SUBSCRIPTION = gql`subscription IsTyping($chatId:
chatId: {_eq: $chatId}
}
) {
meetingId
chatId
userId
typingAt

View File

@ -38,31 +38,14 @@ const TypingIndicator = styled.span`
`;
const TypingIndicatorWrapper = styled.div`
${({ error }) => error && `
color: ${colorDanger};
font-size: calc(${fontSizeBase} * .75);
color: ${colorGrayDark};
text-align: left;
vertical-align: top;
padding: ${borderSize} 0;
position: relative;
height: .93rem;
max-height: .93rem;
`}
${({ info }) => info && `
font-size: calc(${fontSizeBase} * .75);
color: ${colorGrayDark};
text-align: left;
padding: ${borderSize} 0;
position: relative;
height: .93rem;
max-height: .93rem;
`}
${({ spacer }) => spacer && `
height: .93rem;
max-height: .93rem;
`}
margin-bottom: .25rem;
`;
export default {

View File

@ -0,0 +1,65 @@
import React from 'react';
import ChatHeader from './chat-header/component';
import { layoutSelect, layoutSelectInput } from '../../layout/context';
import { Input, Layout } from '../../layout/layoutTypes';
import Styled from './styles';
import ChatMessageListContainer from './chat-message-list/component';
import ChatMessageFormContainer from './chat-message-form/component';
import ChatTypingIndicatorContainer from './chat-typing-indicator/component';
import { PANELS, ACTIONS } from '/imports/ui/components/layout/enums';
import { CircularProgress } from "@mui/material";
import usePendingChat from '/imports/ui/core/local-states/usePendingChat';
import useChat from '/imports/ui/core/hooks/useChat';
import { Chat } from '/imports/ui/Types/chat';
import { layoutDispatch } from '/imports/ui/components/layout/context';
interface ChatProps {
}
const Chat: React.FC<ChatProps> = () => {
return (
<Styled.Chat>
<ChatHeader />
<ChatMessageListContainer />
<ChatMessageFormContainer />
<ChatTypingIndicatorContainer />
</Styled.Chat>
);
};
const ChatLoading: React.FC = () => {
return <Styled.Chat >
<CircularProgress style={{ alignSelf: 'center' }} />
</Styled.Chat>;
};
const ChatContainer: React.FC = () => {
const idChatOpen = layoutSelect((i: Layout) => i.idChatOpen);
const sidebarContent = layoutSelectInput((i: Input) => i.sidebarContent);
const layoutContextDispatch = layoutDispatch();
const chats = useChat((chat) => {
return {
chatId: chat.chatId,
participant: chat.participant,
};
}) as Partial<Chat>[];
const [pendingChat, setPendingChat] = usePendingChat();
if (pendingChat) {
const chat = chats.find((c) => { return c.participant?.userId === pendingChat });
if (chat) {
setPendingChat('');
layoutContextDispatch({
type: ACTIONS.SET_ID_CHAT_OPEN,
value: chat.chatId,
});
}
}
if (sidebarContent.sidebarContentPanel !== PANELS.CHAT) return null;
if (!idChatOpen) return <ChatLoading />;
return <Chat />;
};
export default ChatContainer;

View File

@ -0,0 +1,64 @@
import styled from 'styled-components';
import { colorWhite, colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
import { mdPaddingX } from '/imports/ui/stylesheets/styled-components/general';
export const Chat = styled.div`
background-color: ${colorWhite};
padding: ${mdPaddingX};
padding-bottom: 0;
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: space-around;
overflow: hidden;
height: 100%;
a {
color: ${colorPrimary};
text-decoration: none;
&:focus {
color: ${colorPrimary};
text-decoration: underline;
}
&:hover {
filter: brightness(90%);
text-decoration: underline;
}
&:active {
filter: brightness(85%);
text-decoration: underline;
}
&:hover:focus {
filter: brightness(90%);
text-decoration: underline;
}
&:focus:active {
filter: brightness(85%);
text-decoration: underline;
}
}
u {
text-decoration-line: none;
}
${({ isChrome }) =>
isChrome &&
`
transform: translateZ(0);
`}
@media ${smallOnly} {
transform: none !important;
}
`;
const ChatContent = styled.div`
height: 100%;
display: contents;
`;
const ChatMessages = styled.div``;
export default { Chat, ChatMessages, ChatContent };

View File

@ -10,11 +10,13 @@ import { GroupChatContext } from '../components-data/group-chat-context/context'
import { UsersContext } from '../components-data/users-context/context';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
import Chat from '/imports/ui/components/chat/component';
// import Chat from '/imports/ui/components/chat/component';
import ChatService from './service';
import { layoutSelect, layoutDispatch } from '../layout/context';
import { escapeHtml } from '/imports/utils/string-utils';
import Chat from './chat-graphql/component';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
@ -249,7 +251,7 @@ const ChatContainer = (props) => {
);
};
export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks }) => {
lockContextContainer(injectIntl(withTracker(({ intl, userLocks }) => {
const isChatLockedPublic = userLocks.userPublicChat;
const isChatLockedPrivate = userLocks.userPrivateChat;
@ -267,3 +269,5 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks })
},
};
})(ChatContainer)));
export default Chat;

View File

@ -6,10 +6,10 @@ import GroupChat from '/imports/api/group-chat';
import Annotations from '/imports/api/annotations';
import Users from '/imports/api/users';
import { Annotations as AnnotationsLocal } from '/imports/ui/components/whiteboard/service';
import {
localCollectionRegistry,
} from '/client/collection-mirror-initializer';
import SubscriptionRegistry, { subscriptionReactivity } from '../../services/subscription-registry/subscriptionRegistry';
import { localCollectionRegistry } from '/client/collection-mirror-initializer';
import SubscriptionRegistry, {
subscriptionReactivity,
} from '../../services/subscription-registry/subscriptionRegistry';
import { isChatEnabled } from '/imports/ui/services/features';
const CHAT_CONFIG = Meteor.settings.public.chat;
@ -17,13 +17,39 @@ const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
const PUBLIC_CHAT_TYPE = CHAT_CONFIG.type_public;
const TYPING_INDICATOR_ENABLED = CHAT_CONFIG.typingIndicator.enabled;
const SUBSCRIPTIONS = [
'users', 'meetings', 'polls', 'presentations', 'slides', 'slide-positions', 'captions',
'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat',
'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'meeting-time-remaining',
'local-settings', 'users-typing', 'record-meetings', 'video-streams',
'connection-status', 'voice-call-states', 'external-video-meetings', 'breakouts', 'breakouts-history',
'pads', 'pads-sessions', 'pads-updates', 'notifications', 'audio-captions',
'layout-meetings', 'user-reaction', 'timer',
'users',
'meetings',
'polls',
'presentations',
'slides',
'slide-positions',
'captions',
'voiceUsers',
'whiteboard-multi-user',
'screenshare',
'presentation-pods',
'users-settings',
'guestUser',
'users-infos',
'meeting-time-remaining',
'local-settings',
'users-typing',
'record-meetings',
'video-streams',
'connection-status',
'voice-call-states',
'external-video-meetings',
'breakouts',
'breakouts-history',
'pads',
'pads-sessions',
'pads-updates',
'notifications',
'audio-captions',
'layout-meetings',
'user-reaction',
'timer',
// 'group-chat'
];
const {
localBreakoutsSync,
@ -72,10 +98,13 @@ export default withTracker(() => {
const subscriptionErrorHandler = {
onError: (error) => {
logger.error({
logCode: 'startup_client_subscription_error',
extraInfo: { error },
}, 'Error while subscribing to collections');
logger.error(
{
logCode: 'startup_client_subscription_error',
extraInfo: { error },
},
'Error while subscribing to collections'
);
Session.set('codeError', error.error);
},
};
@ -84,8 +113,11 @@ export default withTracker(() => {
let subscriptionsHandlers = SUBSCRIPTIONS.map((name) => {
let subscriptionHandlers = subscriptionErrorHandler;
if ((!TYPING_INDICATOR_ENABLED && name.indexOf('typing') !== -1)
|| (!isChatEnabled() && name.indexOf('chat') !== -1)) return null;
if (
(!TYPING_INDICATOR_ENABLED && name.indexOf('typing') !== -1) ||
(!isChatEnabled() && name.indexOf('chat') !== -1)
)
return null;
if (name === 'users') {
subscriptionHandlers = {
@ -100,7 +132,7 @@ export default withTracker(() => {
return SubscriptionRegistry.createSubscription(name, subscriptionHandlers);
});
if (currentUser && (oldRole !== currentUser?.role)) {
if (currentUser && oldRole !== currentUser?.role) {
// stop subscription from the client-side as the server-side only watch moderators
if (oldRole === 'VIEWER' && currentUser?.role === 'MODERATOR') {
// let this withTracker re-execute when a subscription is stopped
@ -126,17 +158,17 @@ export default withTracker(() => {
oldRole = currentUser?.role;
}
subscriptionsHandlers = subscriptionsHandlers.filter(obj => obj);
const ready = subscriptionsHandlers.every(handler => handler.ready());
subscriptionsHandlers = subscriptionsHandlers.filter((obj) => obj);
const ready = subscriptionsHandlers.every((handler) => handler.ready());
let groupChatMessageHandler = {};
if (isChatEnabled() && ready) {
const subHandler = {
...subscriptionErrorHandler,
};
// if (isChatEnabled() && ready) {
// const subHandler = {
// ...subscriptionErrorHandler,
// };
groupChatMessageHandler = Meteor.subscribe('group-chat-msg', subHandler);
}
// groupChatMessageHandler = Meteor.subscribe('group-chat-msg', subHandler);
// }
// TODO: Refactor all the late subscribers
let usersPersistentDataHandler = {};
@ -157,8 +189,8 @@ export default withTracker(() => {
...subscriptionErrorHandler,
});
Object.values(localCollectionRegistry).forEach(
(localCollection) => localCollection.checkForStaleData(),
Object.values(localCollectionRegistry).forEach((localCollection) =>
localCollection.checkForStaleData()
);
}

View File

@ -6,8 +6,8 @@ import { Meteor } from 'meteor/meteor'
import Styled from './styles';
import Icon from '/imports/ui/components/common/icon/component';
import { Input, Layout } from '/imports/ui/components/layout/layoutTypes';
import { Chat } from './chatTypes';
import { UseShortcutHelp, useShortcutHelp } from '/imports/ui/components/shortcut-help/useShortcutHelp'
import { Chat } from '/imports/ui/Types/chat';
const intlMessages = defineMessages({
titlePublic: {
@ -25,7 +25,7 @@ const intlMessages = defineMessages({
});
interface ChatListItemProps {
chat: Chat,
chat: Partial<Chat>,
}
const CHAT_CONFIG = Meteor.settings.public.chat;
@ -108,10 +108,9 @@ const ChatListItem = (props: ChatListItemProps) => {
? intl.formatMessage(intlMessages.titlePublic)
: chat.participant.name;
const arialabel = `${localizedChatName} ${
countUnreadMessages > 1
? intl.formatMessage(intlMessages.unreadPlural, { 0: countUnreadMessages })
: intl.formatMessage(intlMessages.unreadSingular)}`;
const arialabel = `${localizedChatName} ${countUnreadMessages > 1
? intl.formatMessage(intlMessages.unreadPlural, { 0: countUnreadMessages })
: intl.formatMessage(intlMessages.unreadSingular)}`;
return (
<Styled.ChatListItem

View File

@ -3,67 +3,73 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group';
import { useSubscription } from '@apollo/client';
import Styled from './styles';
import { defineMessages, useIntl } from 'react-intl';
import {
CHATS_SUBSCRIPTION
} from './queries';
import ChatListItem from './chat-list-item/component'
import { Chat } from './chat-list-item/chatTypes';
import useChat from '/imports/ui/core/hooks/useChat';
import { Chat } from '/imports/ui/Types/chat';
import usePendingChats from '/imports/ui/core/local-states/usePendingChats';
import { layoutDispatch } from '/imports/ui/components/layout/context';
import { ACTIONS, PANELS } from '/imports/ui/components/layout/enums';
const intlMessages = defineMessages({
messagesTitle: {
id: 'app.userList.messagesTitle',
description: 'Title for the messages list',
id: 'app.userList.messagesTitle',
description: 'Title for the messages list',
},
});
const ChatList: React.FC = () => {
const { data } = useSubscription(CHATS_SUBSCRIPTION);
interface ChatListProps {
chats: Partial<Chat>[],
}
const getActiveChats = () => {
if (data) {
const { chat: chats } = data;
const getActiveChats = (chats: Partial<Chat>[]) => {
return chats.map((chat) => (
<CSSTransition
classNames={"transition"}
appear
enter
exit={false}
timeout={0}
component="div"
key={chat.chatId}
>
<Styled.ListTransition >
<ChatListItem
chat={chat}
/>
</Styled.ListTransition>
</CSSTransition>
));
}
const ChatList: React.FC<ChatListProps> = ({ chats }) => {
return chats.map( (chat: Chat) => (
<CSSTransition
classNames={"transition"}
appear
enter
exit={false}
timeout={0}
component="div"
key={chat.chatId}
>
<Styled.ListTransition >
<ChatListItem
chat={chat}
/>
</Styled.ListTransition>
</CSSTransition>
));
} else {
return null;
}
}
const intl = useIntl();
return (
<Styled.Messages>
<Styled.Container>
<Styled.MessagesTitle data-test="messageTitle">
{intl.formatMessage(intlMessages.messagesTitle)}
</Styled.MessagesTitle>
</Styled.Container>
<Styled.ScrollableList
role="tabpanel"
tabIndex={-1}
>
<Styled.List>
<TransitionGroup >
{getActiveChats()}
</TransitionGroup>
</Styled.List>
</Styled.ScrollableList>
</Styled.Messages>)
<Styled.Messages>
<Styled.Container>
<Styled.MessagesTitle data-test="messageTitle">
{intl.formatMessage(intlMessages.messagesTitle)}
</Styled.MessagesTitle>
</Styled.Container>
<Styled.ScrollableList
role="tabpanel"
tabIndex={-1}
>
<Styled.List>
<TransitionGroup >
{getActiveChats(chats) ?? null}
</TransitionGroup>
</Styled.List>
</Styled.ScrollableList>
</Styled.Messages>)
};
export default ChatList;
const ChatListContainer: React.FC = () => {
const chats = useChat((chat) => { return chat; }) as Partial<Chat>[];
return (
<ChatList chats={chats} />
);
};
export default ChatListContainer;

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { User } from '/imports/ui/Types/user';
import {LockSettings, UsersPolicies} from '/imports/ui/Types/meeting';
import { LockSettings, UsersPolicies } from '/imports/ui/Types/meeting';
import { generateActionsPermissions, isVoiceOnlyUser } from './service';
import { useIntl, defineMessages } from 'react-intl';
import {
@ -22,6 +22,7 @@ import { EMOJI_STATUSES } from '/imports/utils/statuses';
import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component';
import BBBMenu from '/imports/ui/components/common/menu/component';
import { setPendingChat } from '/imports/ui/core/local-states/usePendingChat';
interface UserActionsProps {
user: User;
@ -131,7 +132,6 @@ const UserActions: React.FC<UserActionsProps> = ({
usersPolicies,
isBreakout
);
const {
allowedToChangeStatus,
allowedToChatPrivately,
@ -151,7 +151,7 @@ const UserActions: React.FC<UserActionsProps> = ({
const {
disablePrivateChat,
} = lockSettings;
const userLocked = user.locked
&& lockSettings.hasActiveLockSetting
&& user.isModerator;
@ -183,17 +183,18 @@ const UserActions: React.FC<UserActionsProps> = ({
allowed: isChatEnabled()
&& (
currentUser.isModerator ? allowedToChatPrivately
: allowedToChatPrivately && (
!(currentUser.locked && disablePrivateChat)
// TODO: Add check for hasPrivateChat between users
|| user.isModerator
)
: allowedToChatPrivately && (
!(currentUser.locked && disablePrivateChat)
// TODO: Add check for hasPrivateChat between users
|| user.isModerator
)
)
&& !isVoiceOnlyUser(user.userId)
&& !isBreakout,
key: 'activeChat',
label: intl.formatMessage(messages.StartPrivateChat),
onClick: () => {
setPendingChat(user.userId);
setSelected(false);
sendCreatePrivateChat(user);
layoutContextDispatch({
@ -206,7 +207,7 @@ const UserActions: React.FC<UserActionsProps> = ({
});
layoutContextDispatch({
type: ACTIONS.SET_ID_CHAT_OPEN,
value: user.userId,
value: '',
});
},
icon: 'chat',
@ -366,53 +367,53 @@ const UserActions: React.FC<UserActionsProps> = ({
})),
];
const actions = showNestedOptions
const actions = showNestedOptions
? nestedOptions.filter(key => key.allowed)
: dropdownOptions.filter(key => key.allowed);
if (!actions.length) return children;
return <div>
<BBBMenu
trigger={
(
<div
isActionsOpen={selected}
selected={selected === true}
tabIndex={-1}
onClick={() => setSelected(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSelected(true);
}
}}
role="button"
>
{children}
</div>
)
}
actions={actions}
selectedEmoji={user.emoji}
onCloseCallback={() =>{
setSelected(false);
setShowNestedOptions(false);
}}
open={selected}
/>
{isConfirmationModalOpen ? <ConfirmationModal
intl={intl}
titleMessageId="app.userList.menu.removeConfirmation.label"
titleMessageExtra={user.name}
checkboxMessageId="app.userlist.menu.removeConfirmation.desc"
confirmParam={user.userId}
onConfirm={removeUser}
confirmButtonDataTest="removeUserConfirmation"
{...{
onRequestClose: () => setIsConfirmationModalOpen(false),
priority: "low",
setIsOpen: setIsConfirmationModalOpen,
isOpen: isConfirmationModalOpen
}}
/> : null}
<BBBMenu
trigger={
(
<div
isActionsOpen={selected}
selected={selected === true}
tabIndex={-1}
onClick={() => setSelected(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSelected(true);
}
}}
role="button"
>
{children}
</div>
)
}
actions={actions}
selectedEmoji={user.emoji}
onCloseCallback={() => {
setSelected(false);
setShowNestedOptions(false);
}}
open={selected}
/>
{isConfirmationModalOpen ? <ConfirmationModal
intl={intl}
titleMessageId="app.userList.menu.removeConfirmation.label"
titleMessageExtra={user.name}
checkboxMessageId="app.userlist.menu.removeConfirmation.desc"
confirmParam={user.userId}
onConfirm={removeUser}
confirmButtonDataTest="removeUserConfirmation"
{...{
onRequestClose: () => setIsConfirmationModalOpen(false),
priority: "low",
setIsOpen: setIsConfirmationModalOpen,
isOpen: isConfirmationModalOpen
}}
/> : null}
</div>;
};

View File

@ -0,0 +1,7 @@
const enum Events {
SENT_MESSAGE = 'sentMessage',
RESTORE_WELCOME_MESSAGES = 'restoreWelcomeMessages',
}
export default Events;

View File

@ -0,0 +1,29 @@
import { gql } from '@apollo/client';
export const CHATS_SUBSCRIPTION = gql`
subscription {
chat(
order_by: [
{ public: desc }
{ totalUnread: desc }
{ participant: { name: asc, userId: asc } }
]
) {
chatId
participant {
userId
name
role
color
loggedOut
avatar
isOnline
isModerator
}
totalMessages
totalUnread
public
lastSeenAt
}
}
`;

View File

@ -0,0 +1,78 @@
import { gql } from "@apollo/client";
export const MEETING_SUBSCRIPTION = gql`
subscription MeetingSubscription {
meeting {
createdTime
disabledFeatures
duration
extId
lockSettings {
disableCam
disableMic
disableNotes
disablePrivateChat
disablePublicChat
hasActiveLockSetting
hideUserList
hideViewersCursor
webcamsOnlyForModerator
}
maxPinnedCameras
meetingCameraCap
meetingId
name
notifyRecordingIsOn
presentationUploadExternalDescription
presentationUploadExternalUrl
recordingPolicies {
allowStartStopRecording
autoStartRecording
record
keepEvents
}
screenshare {
hasAudio
screenshareId
stream
vidHeight
vidWidth
voiceConf
screenshareConf
}
usersPolicies {
allowModsToEjectCameras
allowModsToUnmuteUsers
authenticatedGuest
guestPolicy
maxUserConcurrentAccesses
maxUsers
meetingLayout
moderatorsCanMuteAudio
moderatorsCanUnmuteAudio
userCameraCap
webcamsOnlyForModerator
}
isBreakout
breakoutPolicies {
breakoutRooms
captureNotes
captureNotesFilename
captureSlides
captureSlidesFilename
freeJoin
parentId
privateChatEnabled
record
sequence
}
html5InstanceId
voiceSettings {
dialNumber
muteOnStart
voiceConf
telVoice
}
}
}
`;

View File

@ -0,0 +1,17 @@
import { createUseSubsciption } from "./createUseSubscription";
import { CHATS_SUBSCRIPTION } from "../graphql/queries/chatSubscription";
import { Chat } from "../../Types/chat";
const useChatSubscription = createUseSubsciption<Partial<Chat>>(CHATS_SUBSCRIPTION);
const useChat = (fn: (c: Partial<Chat>)=> Partial<Chat>, chatId?: string ): Array<Partial<Chat>> | Partial<Chat> | null =>{
const chats = useChatSubscription(fn);
if (chatId) {
return chats.find((c) => {
return c.chatId === chatId
}) ?? null;
}
return chats;
};
export default useChat;

View File

@ -5,7 +5,7 @@ import { User } from "../../Types/user";
const useCurrentUserSubscription = createUseSubsciption<Partial<User>>(CURRENT_USER_SUBSCRIPTION, false);
export const useCurrentUser = (fn: (c: Partial<User>)=> Array<Partial<User>>)=>{
export const useCurrentUser = (fn: (c: Partial<User>)=> Partial<User>)=>{
const currentUser = useCurrentUserSubscription(fn)[0];
return currentUser;
};

View File

@ -0,0 +1,11 @@
import { createUseSubsciption } from "./createUseSubscription";
import { MEETING_SUBSCRIPTION } from "../graphql/queries/meetingSubscription";
import { Meeting } from "../../Types/meeting";
const useMeetingSubscription = createUseSubsciption<Partial<Meeting>>(MEETING_SUBSCRIPTION, false);
export const useMeeting = (fn: (c: Partial<Meeting>)=> Partial<Meeting>): Partial<Meeting>=>{
const meeting = useMeetingSubscription(fn)[0];
return meeting;
};

View File

@ -0,0 +1,21 @@
import { makeVar, useReactiveVar } from '@apollo/client';
function createUseLocalState<T>(initialValue: T) {
const localState = makeVar(initialValue);
function useLocalState(): [T, (value: T) => void] {
const reactiveLocalState = useReactiveVar(localState);
return [reactiveLocalState, changeLocalState];
}
function changeLocalState(value: T | Function) {
if (typeof value === 'function') {
return localState(value(localState()));
}
return localState(value);
}
return [useLocalState, changeLocalState, localState];
}
export default createUseLocalState;

View File

@ -0,0 +1,7 @@
import createUseLocalState from './createUseLocalState';
const initialPendingChat: string = '';
const [usePendingChat, setPendingChat] = createUseLocalState<string>(initialPendingChat);
export default usePendingChat;
export { setPendingChat };

View File

@ -1,28 +1,35 @@
export function throttle(func, delay, options = {}) {
let lastInvocation = 0;
let isWaiting = false;
let timeoutId;
let lastExecTime = 0;
let leadingExec = true;
const { leading = true, trailing = true } = options;
const leading = options.leading !== undefined ? options.leading : true;
const trailing = options.trailing !== undefined ? options.trailing : true;
return function () {
const context = this;
const args = arguments;
const elapsed = Date.now() - lastExecTime;
return function throttled(...args) {
const invokeFunction = () => {
lastInvocation = Date.now();
isWaiting = false;
func.apply(this, args);
};
function execute() {
func.apply(context, args);
lastExecTime = Date.now();
if (!isWaiting) {
if (leading) {
invokeFunction();
} else {
isWaiting = true;
}
const currentTime = Date.now();
const timeSinceLastInvocation = currentTime - lastInvocation;
if (timeSinceLastInvocation >= delay) {
clearTimeout(timeoutId);
invokeFunction();
} else if (trailing) {
clearTimeout(timeoutId);
timeoutId = setTimeout(invokeFunction, delay - timeSinceLastInvocation);
}
}
if (leadingExec && leading) {
execute();
leadingExec = false;
} else if (!timeoutId && trailing) {
timeoutId = setTimeout(function () {
execute();
timeoutId = null;
}, delay - elapsed);
}
}
}
};
}

View File

@ -84,6 +84,7 @@
"react-toggle": "^4.1.2",
"react-transition-group": "^2.9.0",
"react-virtualized": "^9.22.4",
"recharts": "^2.7.2",
"reconnecting-websocket": "~v4.4.0",
"redis": "^3.1.2",
"sanitize-html": "2.7.1",

File diff suppressed because it is too large Load Diff