UNPKG

mediasfu-reactnative-expo

Version:

mediasfu-reactnative-expo – Expo-managed React Native WebRTC SDK for video conferencing, webinars, live streaming, broadcast, screen sharing, whiteboard, chat, recording, live subtitles, translation, and AI agent rooms on iOS, Android, and web. Prebuilt r

778 lines (772 loc) 29.3 kB
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions, } from 'react-native'; import { FontAwesome5 } from '@expo/vector-icons'; import { sendMessage } from '../../methods/messageMethods/sendMessage'; import { getModalPosition } from '../../methods/utils/getModalPosition'; import { getModernColors, getModernModalCardStyle, getModernSidePanelStyle, resolveIsDarkMode, } from '../core/modernTheme'; const isDirectMessagingEvent = (eventType) => (eventType === 'conference' || eventType === 'webinar'); export const ModernMessagesModal = ({ isMessagesModalVisible, onMessagesClose, onSendMessagePress = sendMessage, messages, position = 'topRight', backgroundColor, isDarkMode, eventType, member, islevel, coHostResponsibility, coHost, startDirectMessage, directMessageDetails, updateStartDirectMessage, updateDirectMessageDetails, showAlert, roomName, socket, chatSetting, style, renderContent, renderContainer, renderMode = 'modal', }) => { const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const darkMode = resolveIsDarkMode({ isDarkMode, backgroundColor }); const colors = getModernColors(darkMode); const showDirectTab = isDirectMessagingEvent(eventType); const isEmbedded = renderMode === 'sidebar' || renderMode === 'inline'; const useDesktopSidePanel = !isEmbedded && Platform.OS === 'web' && windowWidth >= 1200; const modalWidth = useMemo(() => Math.min(Math.max(windowWidth * 0.88, 320), useDesktopSidePanel ? 420 : 460), [useDesktopSidePanel, windowWidth]); const modalHeight = useMemo(() => Math.min(Math.max(windowHeight * 0.72, 420), 680), [windowHeight]); const dimensions = useMemo(() => ({ width: modalWidth, height: modalHeight }), [modalHeight, modalWidth]); const [activeTab, setActiveTab] = useState(showDirectTab ? 'direct' : 'group'); const [directMessageText, setDirectMessageText] = useState(''); const [groupMessageText, setGroupMessageText] = useState(''); const [replyInfo, setReplyInfo] = useState(null); const [senderId, setSenderId] = useState(null); const [focusedInput, setFocusedInput] = useState(false); const inputRef = useRef(null); const scrollViewRef = useRef(null); useEffect(() => { if (!showDirectTab && activeTab !== 'group') { setActiveTab('group'); } }, [activeTab, showDirectTab]); useEffect(() => { if (startDirectMessage && directMessageDetails && showDirectTab) { setActiveTab('direct'); setFocusedInput(true); setReplyInfo({ text: 'Replying to:', username: directMessageDetails.name }); setSenderId(directMessageDetails.name); return; } if (!showDirectTab) { setFocusedInput(false); } }, [directMessageDetails, showDirectTab, startDirectMessage]); useEffect(() => { if (focusedInput && activeTab === 'direct') { const focusHandle = setTimeout(() => { inputRef.current?.focus(); }, 0); return () => clearTimeout(focusHandle); } return undefined; }, [activeTab, focusedInput, senderId]); const directMessages = useMemo(() => { const chatAllowed = coHostResponsibility?.find((item) => item.name === 'chat')?.value; return messages.filter((message) => !message.group && (message.sender === member || message.receivers.includes(member) || islevel === '2' || (coHost === member && chatAllowed === true))); }, [coHost, coHostResponsibility, islevel, member, messages]); const groupMessages = useMemo(() => messages.filter((message) => message.group), [messages]); const currentMessages = activeTab === 'direct' ? directMessages : groupMessages; const currentMessageText = activeTab === 'direct' ? directMessageText : groupMessageText; const setCurrentMessageText = useCallback((value) => { if (activeTab === 'direct') { setDirectMessageText(value); return; } setGroupMessageText(value); }, [activeTab]); const switchToDirect = useCallback(() => { if (!showDirectTab) { return; } setActiveTab('direct'); setFocusedInput(true); }, [showDirectTab]); const switchToGroup = useCallback(() => { setActiveTab('group'); setFocusedInput(false); }, []); const openReplyInput = useCallback((nextSenderId) => { setActiveTab('direct'); setFocusedInput(true); setReplyInfo({ text: 'Replying to:', username: nextSenderId }); setSenderId(nextSenderId); updateDirectMessageDetails(null); updateStartDirectMessage(false); }, [updateDirectMessageDetails, updateStartDirectMessage]); const resetDirectMessageState = useCallback(() => { setReplyInfo(null); setSenderId(null); setFocusedInput(false); updateDirectMessageDetails(null); updateStartDirectMessage(false); }, [updateDirectMessageDetails, updateStartDirectMessage]); const handleSend = useCallback(async () => { const message = currentMessageText; if (!message) { showAlert?.({ message: 'Please enter a message', type: 'danger', duration: 3000, }); return; } if (message.length > 350) { showAlert?.({ message: 'Message is too long', type: 'danger', duration: 3000, }); return; } if (message.trim() === '') { showAlert?.({ message: 'Message is not valid.', type: 'danger', duration: 3000, }); return; } if (activeTab === 'direct' && !senderId && islevel === '2') { showAlert?.({ message: 'Please select a message to reply to', type: 'danger', duration: 3000, }); return; } try { await onSendMessagePress({ message, receivers: activeTab === 'direct' && senderId ? [senderId] : [], group: activeTab === 'group', messagesLength: messages.length, member, sender: member, islevel, showAlert, coHostResponsibility, coHost, roomName, socket, chatSetting, }); if (activeTab === 'direct') { setDirectMessageText(''); resetDirectMessageState(); } else { setGroupMessageText(''); } } catch { showAlert?.({ message: 'Failed to send message.', type: 'danger', duration: 3000, }); } }, [ activeTab, chatSetting, coHost, coHostResponsibility, currentMessageText, islevel, member, messages.length, onSendMessagePress, resetDirectMessageState, roomName, senderId, showAlert, socket, ]); const getPlaceholder = useCallback(() => { if (activeTab === 'direct') { if (senderId) { return `Send a direct message to ${senderId}`; } if (directMessageDetails) { return `Send a direct message to ${directMessageDetails.name}`; } if (islevel !== '2') { return 'Send a direct message to the host'; } return 'Select a message to reply to'; } return eventType === 'chat' ? 'Send a message' : 'Send a message to everyone'; }, [activeTab, directMessageDetails, eventType, islevel, senderId]); const showSelectRecipientNotice = activeTab === 'direct' && !senderId && islevel === '2'; const showPrivacyNotice = activeTab === 'direct' && islevel !== '2'; const contentBody = (<View style={styles.contentShell}> <View style={[styles.header, { borderBottomColor: colors.border }]}> <View style={styles.titleRow}> <View style={[styles.titleIcon, { backgroundColor: colors.accentSoft }]}> <FontAwesome5 name="comments" size={15} color={colors.accent}/> </View> <View> <Text style={[styles.titleText, { color: colors.text }]}>Messages</Text> <Text style={[styles.subtitleText, { color: colors.textMuted }]}>Room chat and direct replies</Text> </View> </View> <Pressable accessibilityRole="button" accessibilityLabel="Close messages" onPress={onMessagesClose} style={({ pressed }) => [ styles.iconButton, { borderColor: colors.border, backgroundColor: pressed ? colors.accentSoft : colors.surfaceStrong, }, ]}> <FontAwesome5 name="times" size={14} color={colors.textMuted}/> </Pressable> </View> {showDirectTab ? (<View style={[styles.tabsContainer, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}> <Pressable accessibilityRole="button" accessibilityState={{ selected: activeTab === 'direct' }} onPress={switchToDirect} style={({ pressed }) => [ styles.tabButton, { backgroundColor: activeTab === 'direct' ? colors.accent : pressed ? colors.accentSoft : 'transparent', }, ]}> <FontAwesome5 name="user" size={12} color={activeTab === 'direct' ? colors.invertedText : colors.textMuted}/> <Text style={[styles.tabText, { color: activeTab === 'direct' ? colors.invertedText : colors.textMuted }]}>Direct</Text> {directMessages.length > 0 ? (<View style={[ styles.tabBadge, { backgroundColor: activeTab === 'direct' ? 'rgba(255,255,255,0.18)' : colors.accentSoft, }, ]}> <Text style={[styles.tabBadgeText, { color: activeTab === 'direct' ? colors.invertedText : colors.accent }]}> {directMessages.length} </Text> </View>) : null} </Pressable> <Pressable accessibilityRole="button" accessibilityState={{ selected: activeTab === 'group' }} onPress={switchToGroup} style={({ pressed }) => [ styles.tabButton, { backgroundColor: activeTab === 'group' ? colors.accent : pressed ? colors.accentSoft : 'transparent', }, ]}> <FontAwesome5 name="users" size={12} color={activeTab === 'group' ? colors.invertedText : colors.textMuted}/> <Text style={[styles.tabText, { color: activeTab === 'group' ? colors.invertedText : colors.textMuted }]}>Group</Text> {groupMessages.length > 0 ? (<View style={[ styles.tabBadge, { backgroundColor: activeTab === 'group' ? 'rgba(255,255,255,0.18)' : colors.accentSoft, }, ]}> <Text style={[styles.tabBadgeText, { color: activeTab === 'group' ? colors.invertedText : colors.accent }]}> {groupMessages.length} </Text> </View>) : null} </Pressable> </View>) : null} <View style={styles.body}> {showSelectRecipientNotice ? (<View style={[ styles.noticeCard, { backgroundColor: darkMode ? 'rgba(59, 130, 246, 0.12)' : 'rgba(37, 99, 235, 0.08)', borderColor: darkMode ? 'rgba(96, 165, 250, 0.22)' : 'rgba(37, 99, 235, 0.16)', }, ]}> <View style={styles.noticeTitleRow}> <FontAwesome5 name="user-friends" size={12} color={colors.accent}/> <Text style={[styles.noticeTitleText, { color: colors.accent }]}>Select a recipient</Text> </View> <Text style={[styles.noticeBodyText, { color: colors.textMuted }]}>Reply to a direct message above, or start a message from the Participants panel.</Text> </View>) : null} {showPrivacyNotice ? (<View style={[ styles.noticeCard, { backgroundColor: darkMode ? 'rgba(234, 179, 8, 0.12)' : 'rgba(245, 158, 11, 0.08)', borderColor: darkMode ? 'rgba(250, 204, 21, 0.18)' : 'rgba(217, 119, 6, 0.14)', }, ]}> <View style={styles.noticeTitleRow}> <FontAwesome5 name="lock" size={12} color={darkMode ? '#fcd34d' : '#b45309'}/> <Text style={[styles.noticeTitleText, { color: darkMode ? '#fcd34d' : '#b45309' }]}>Private messages</Text> </View> <Text style={[styles.noticeBodyText, { color: colors.textMuted }]}>Direct messages you send here go straight to the host and stay private.</Text> </View>) : null} <View style={[styles.messagesPanel, { backgroundColor: colors.surfaceStrong, borderColor: colors.border }]}> {currentMessages.length === 0 ? (<View style={styles.emptyState}> <View style={[styles.emptyStateIcon, { backgroundColor: colors.accentSoft }]}> <FontAwesome5 name={activeTab === 'direct' ? 'user' : 'users'} size={18} color={colors.accent}/> </View> <Text style={[styles.emptyStateTitle, { color: colors.text }]}> {activeTab === 'direct' ? 'No direct messages yet' : 'No group messages yet'} </Text> <Text style={[styles.emptyStateBody, { color: colors.textMuted }]}> {activeTab === 'direct' ? islevel === '2' ? 'Start a conversation by replying to a message or using the Participants panel.' : 'Send a private message to the host from here.' : 'Send a message to everyone in the room.'} </Text> </View>) : (<ScrollView ref={scrollViewRef} contentContainerStyle={styles.messagesList} onContentSizeChange={() => { scrollViewRef.current?.scrollToEnd({ animated: true }); }} showsVerticalScrollIndicator={false}> {currentMessages.map((message, index) => { const isMine = message.sender === member; const directReceiverLabel = isMine && !message.group ? message.receivers .filter((receiver) => typeof receiver === 'string' && receiver.trim().length > 0) .join(', ') : ''; const metaBadgeBackgroundColor = isMine ? 'rgba(255,255,255,0.12)' : colors.surfaceStrong; const metaBadgeBorderColor = isMine ? 'rgba(255,255,255,0.18)' : colors.border; const metaTextColor = isMine ? 'rgba(255,255,255,0.82)' : colors.textMuted; const timestampBadgeBackgroundColor = isMine ? 'rgba(255,255,255,0.16)' : colors.surfaceStrong; const timestampTextColor = isMine ? 'rgba(255,255,255,0.78)' : colors.textMuted; return (<View key={`${message.sender}-${message.timestamp}-${index}`} style={[ styles.messageBubble, isMine ? styles.messageBubbleMine : styles.messageBubbleOther, { alignSelf: isMine ? 'flex-end' : 'flex-start', backgroundColor: isMine ? colors.accent : colors.surfaceMuted, borderColor: isMine ? colors.accent : colors.border, }, ]}> <View style={styles.messageMetaRow}> <View style={styles.messageMetaTextRow}> {directReceiverLabel ? (<View style={[ styles.messageMetaBadge, { backgroundColor: metaBadgeBackgroundColor, borderColor: metaBadgeBorderColor, }, ]}> <Text style={[styles.receiverText, { color: metaTextColor }]}> To {directReceiverLabel} </Text> </View>) : null} {!isMine ? (<View style={[ styles.messageMetaBadge, styles.senderBadge, { backgroundColor: colors.surfaceStrong, borderColor: colors.border, }, ]}> <Text style={[styles.senderText, { color: colors.text }]}>{message.sender}</Text> </View>) : null} </View> <View style={styles.messageMetaActions}> <View style={[ styles.timestampBadge, { backgroundColor: timestampBadgeBackgroundColor, borderColor: metaBadgeBorderColor, }, ]}> <FontAwesome5 name="clock" size={9} color={timestampTextColor}/> <Text style={[styles.timestampText, { color: timestampTextColor }]}> {message.timestamp} </Text> </View> {!isMine && !message.group ? (<Pressable accessibilityRole="button" accessibilityLabel={`Reply to ${message.sender}`} onPress={() => openReplyInput(message.sender)} style={({ pressed }) => [ styles.replyButton, { backgroundColor: pressed ? colors.accentSoft : colors.surfaceStrong, borderColor: colors.border, }, ]}> <FontAwesome5 name="reply" size={11} color={colors.textMuted}/> </Pressable>) : null} </View> </View> <Text style={[styles.messageText, { color: isMine ? colors.invertedText : colors.text }]}> {message.message} </Text> </View>); })} </ScrollView>)} </View> <View style={[styles.inputSection, { borderTopColor: colors.border }]}> {replyInfo ? (<View style={[ styles.replyBanner, { backgroundColor: darkMode ? 'rgba(59, 130, 246, 0.14)' : 'rgba(37, 99, 235, 0.1)', borderColor: darkMode ? 'rgba(96, 165, 250, 0.2)' : 'rgba(37, 99, 235, 0.16)', }, ]}> <View style={styles.replyBannerTextRow}> <FontAwesome5 name="reply" size={11} color={colors.accent}/> <Text style={[styles.replyBannerLabel, { color: colors.accent }]}>{replyInfo.text}</Text> <Text style={[styles.replyBannerValue, { color: darkMode ? '#fcd34d' : '#b45309' }]}>{replyInfo.username}</Text> </View> <Pressable accessibilityRole="button" accessibilityLabel="Clear direct message recipient" onPress={resetDirectMessageState} style={({ pressed }) => [ styles.replyBannerDismiss, { backgroundColor: pressed ? colors.accentSoft : 'transparent' }, ]}> <FontAwesome5 name="times" size={11} color={colors.textMuted}/> </Pressable> </View>) : null} <View style={styles.inputRow}> <TextInput ref={inputRef} multiline maxLength={350} onChangeText={setCurrentMessageText} placeholder={getPlaceholder()} placeholderTextColor={colors.textMuted} style={[ styles.input, { backgroundColor: colors.surfaceStrong, borderColor: colors.border, color: colors.text, }, ]} textAlignVertical="top" value={currentMessageText}/> <Pressable accessibilityRole="button" accessibilityLabel={activeTab === 'direct' && senderId ? `Send to ${senderId}` : 'Send message'} disabled={!currentMessageText.trim()} onPress={handleSend} style={({ pressed }) => [ styles.sendButton, { backgroundColor: !currentMessageText.trim() ? colors.surfaceMuted : pressed ? colors.accentAlt : colors.accent, opacity: !currentMessageText.trim() ? 0.62 : 1, }, ]}> <FontAwesome5 name="paper-plane" size={14} color={colors.invertedText}/> </Pressable> </View> </View> </View> </View>); const content = renderContent ? renderContent({ defaultContent: contentBody, dimensions }) : contentBody; if (isEmbedded) { return (<View style={styles.embeddedContainer}> <View style={[styles.embeddedShell, { backgroundColor: colors.surface }, style]}>{content}</View> </View>); } const overlayStyle = [ styles.modalContainer, useDesktopSidePanel ? styles.desktopSidebarContainer : getModalPosition({ position }), { backgroundColor: useDesktopSidePanel ? 'rgba(15, 23, 42, 0.14)' : 'rgba(15, 23, 42, 0.28)' }, ]; const shellStyle = [ styles.modalShell, getModernModalCardStyle(darkMode), useDesktopSidePanel ? getModernSidePanelStyle(darkMode) : { width: modalWidth, height: modalHeight }, { backgroundColor: backgroundColor ?? colors.surface }, style, ]; const defaultContainer = (<Modal animationType="fade" transparent visible={isMessagesModalVisible} onRequestClose={onMessagesClose}> <Pressable onPress={onMessagesClose} style={overlayStyle}> <Pressable onPress={() => undefined} style={shellStyle}> {content} </Pressable> </Pressable> </Modal>); return renderContainer ? renderContainer({ defaultContainer, dimensions }) : defaultContainer; }; const styles = StyleSheet.create({ modalContainer: { flex: 1, padding: 12, }, desktopSidebarContainer: { justifyContent: 'flex-start', alignItems: 'flex-end', padding: 0, }, modalShell: { overflow: 'hidden', }, embeddedContainer: { flex: 1, minHeight: 0, width: '100%', overflow: 'hidden', }, embeddedShell: { flex: 1, minHeight: 0, width: '100%', overflow: 'hidden', }, contentShell: { flex: 1, minHeight: 0, overflow: 'hidden', }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderBottomWidth: 1, paddingHorizontal: 20, paddingVertical: 16, }, titleRow: { flexDirection: 'row', alignItems: 'center', }, titleIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center', marginRight: 12, }, titleText: { fontSize: 18, fontWeight: '700', }, subtitleText: { fontSize: 12, marginTop: 2, }, iconButton: { width: 38, height: 38, borderRadius: 19, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, tabsContainer: { flexDirection: 'row', borderWidth: 1, borderRadius: 999, marginHorizontal: 20, marginTop: 16, padding: 4, flexShrink: 0, }, tabButton: { flex: 1, minWidth: 0, borderRadius: 999, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 10, paddingHorizontal: 12, }, tabText: { fontSize: 13, fontWeight: '700', marginLeft: 8, }, tabBadge: { minWidth: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', marginLeft: 8, paddingHorizontal: 6, }, tabBadgeText: { fontSize: 11, fontWeight: '800', }, body: { flex: 1, flexBasis: 0, flexShrink: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 16, paddingBottom: 12, overflow: 'hidden', }, noticeCard: { borderWidth: 1, borderRadius: 16, paddingHorizontal: 14, paddingVertical: 12, marginBottom: 12, }, noticeTitleRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6, }, noticeTitleText: { fontSize: 12, fontWeight: '700', marginLeft: 8, }, noticeBodyText: { fontSize: 12, lineHeight: 18, }, messagesPanel: { flex: 1, flexBasis: 0, flexShrink: 1, minHeight: 0, borderWidth: 1, borderRadius: 22, overflow: 'hidden', }, emptyState: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28, }, emptyStateIcon: { width: 48, height: 48, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginBottom: 14, }, emptyStateTitle: { fontSize: 15, fontWeight: '700', textAlign: 'center', }, emptyStateBody: { fontSize: 12, lineHeight: 18, textAlign: 'center', marginTop: 6, }, messagesList: { padding: 16, }, messageBubble: { maxWidth: '86%', borderWidth: 1, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 12, marginBottom: 12, }, messageBubbleMine: { borderBottomRightRadius: 6, }, messageBubbleOther: { borderBottomLeftRadius: 6, }, messageMetaRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, }, messageMetaTextRow: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', flex: 1, marginRight: 8, gap: 6, }, messageMetaActions: { flexDirection: 'row', alignItems: 'center', gap: 8, marginLeft: 8, }, messageMetaBadge: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 4, }, senderBadge: { maxWidth: '100%', }, senderText: { fontSize: 11, fontWeight: '700', }, receiverText: { fontSize: 11, fontWeight: '600', }, timestampBadge: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 4, gap: 5, }, timestampText: { fontSize: 10, fontWeight: '600', }, replyButton: { width: 24, height: 24, borderRadius: 12, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, messageText: { fontSize: 14, lineHeight: 21, }, inputSection: { flexShrink: 0, paddingTop: 14, marginTop: 14, borderTopWidth: 1, }, replyBanner: { borderWidth: 1, borderRadius: 16, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 10, marginBottom: 12, }, replyBannerTextRow: { flexDirection: 'row', alignItems: 'center', flex: 1, marginRight: 8, }, replyBannerLabel: { fontSize: 12, fontWeight: '700', marginLeft: 8, marginRight: 4, }, replyBannerValue: { fontSize: 12, fontWeight: '700', }, replyBannerDismiss: { width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center', }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', }, input: { flex: 1, minHeight: 52, maxHeight: 132, borderWidth: 1, borderRadius: 18, paddingHorizontal: 14, paddingTop: 12, paddingBottom: 12, fontSize: 14, }, sendButton: { width: 48, height: 48, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginLeft: 12, }, }); export default ModernMessagesModal; //# sourceMappingURL=ModernMessagesModal.js.map