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:
parent
8fde225ade
commit
f2e0fd43e9
@ -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)
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -8,4 +8,5 @@ export interface Chat {
|
||||
totalUnread: number;
|
||||
userId: string;
|
||||
participant?: User;
|
||||
lastSeenAt: number;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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,
|
||||
}}
|
||||
/>;
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
@ -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');
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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;
|
||||
}
|
||||
`;
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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%;
|
||||
`;
|
||||
|
@ -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}
|
||||
/>
|
||||
};
|
||||
|
||||
|
@ -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
|
@ -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 {
|
||||
|
@ -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;
|
@ -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 };
|
@ -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;
|
||||
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>;
|
||||
};
|
||||
|
||||
|
7
bigbluebutton-html5/imports/ui/core/events/events.ts
Normal file
7
bigbluebutton-html5/imports/ui/core/events/events.ts
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
const enum Events {
|
||||
SENT_MESSAGE = 'sentMessage',
|
||||
RESTORE_WELCOME_MESSAGES = 'restoreWelcomeMessages',
|
||||
}
|
||||
|
||||
export default Events;
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
17
bigbluebutton-html5/imports/ui/core/hooks/useChat.ts
Normal file
17
bigbluebutton-html5/imports/ui/core/hooks/useChat.ts
Normal 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;
|
@ -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;
|
||||
};
|
11
bigbluebutton-html5/imports/ui/core/hooks/useMeeting.ts
Normal file
11
bigbluebutton-html5/imports/ui/core/hooks/useMeeting.ts
Normal 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;
|
||||
};
|
@ -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;
|
@ -0,0 +1,7 @@
|
||||
import createUseLocalState from './createUseLocalState';
|
||||
|
||||
const initialPendingChat: string = '';
|
||||
const [usePendingChat, setPendingChat] = createUseLocalState<string>(initialPendingChat);
|
||||
|
||||
export default usePendingChat;
|
||||
export { setPendingChat };
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user