diff --git a/import.sql b/import.sql index bce3245c6..bf0ffaa63 100644 --- a/import.sql +++ b/import.sql @@ -6,9 +6,17 @@ # ALTER TABLE npwd_messages ADD COLUMN `is_embed` tinyint(4) NOT NULL DEFAULT 0; # ALTER TABLE npwd_messages ADD COLUMN `embed` varchar(512) NOT NULL DEFAULT ''; + +# Group Chat Overhaul SQL Update +# ALTER TABLE npwd_messages ADD COLUMN `is_system` tinyint(4) NOT NULL DEFAULT 0; +# ALTER TABLE npwd_messages ADD COLUMN `system_type` varchar(48) NOT NULL DEFAULT ''; +# ALTER TABLE npwd_messages ADD COLUMN `system_number` varchar(48) NOT NULL DEFAULT ''; +# ALTER TABLE npwd_messages_conversations ADD COLUMN `owner` varchar(48) NOT NULL DEFAULT ''; + #match voice messages update # ALTER TABLE npwd_match_profiles ADD COLUMN `voiceMessage` varchar(512) DEFAULT NULL; + CREATE TABLE IF NOT EXISTS `npwd_twitter_profiles` ( `id` int NOT NULL AUTO_INCREMENT, @@ -148,6 +156,9 @@ CREATE TABLE IF NOT EXISTS `npwd_messages` `author` varchar(255) NOT NULL, `is_embed` tinyint(4) NOT NULL default 0, `embed` varchar(512) NOT NULL DEFAULT '', + `is_system` tinyint(4) NOT NULL default 0, + `system_type` varchar(48) NOT NULL default '', + `system_number` varchar(48) NOT NULL default '', PRIMARY KEY (id), INDEX `user_identifier` (`user_identifier`) ); @@ -160,6 +171,7 @@ CREATE TABLE `npwd_messages_conversations` `createdAt` TIMESTAMP NOT NULL DEFAULT current_timestamp(), `updatedAt` TIMESTAMP NOT NULL DEFAULT current_timestamp(), `last_message_id` INT(11) NULL DEFAULT NULL, + `owner` varchar(48) NOT NULL DEFAULT '', `is_group_chat` TINYINT(4) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) USING BTREE ); diff --git a/phone/src/apps/messages/components/list/MessageGroupItem.tsx b/phone/src/apps/messages/components/list/MessageGroupItem.tsx index ad3f70fe0..17a67a190 100644 --- a/phone/src/apps/messages/components/list/MessageGroupItem.tsx +++ b/phone/src/apps/messages/components/list/MessageGroupItem.tsx @@ -93,9 +93,21 @@ const MessageGroupItem = ({ invisible={messageConversation.unreadCount <= 0} > {messageConversation.isGroupChat ? ( - + <> + {messageConversation.avatar ? ( + + ) : ( + + )} + ) : ( - + <> + {getContact()?.avatar ? ( + + ) : ( + {getContact().display.charAt(0)} + )} + )} diff --git a/phone/src/apps/messages/components/modal/AddParticipantModal.tsx b/phone/src/apps/messages/components/modal/AddParticipantModal.tsx new file mode 100644 index 000000000..7b3a0098f --- /dev/null +++ b/phone/src/apps/messages/components/modal/AddParticipantModal.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; +import Modal from '../../../../ui/components/Modal'; +import { Box, Popper, Autocomplete, Button } from '@mui/material'; +import { TextField } from '@ui/components/Input'; +import { PreDBContact, Contact } from '@typings/contact'; +import { useContactsValue } from '../../../contacts/hooks/state'; +import { useTranslation } from 'react-i18next'; + +const AddParticipantModal = ({ + open, + onClose, + participants, + myPhoneNumber, + handleAddGroupMembers, +}: { + open: boolean; + onClose: () => void; + participants: string[]; + myPhoneNumber: string; + handleAddGroupMembers: (members: string[]) => void; +}) => { + const [newParticipants, setNewParticipants] = useState([]); + const [allowedContact, setAllowedContact] = useState([]); + const contacts = useContactsValue(); + const [t] = useTranslation(); + + useEffect(() => { + setAllowedContact(contacts.filter((contact) => !participants.includes(contact.number))); + }, [participants, contacts]); + + const handleSubmit = () => { + const selectedParticipants = newParticipants.map((participant) => participant.number); + handleAddGroupMembers(selectedParticipants); + setNewParticipants([]); + onClose(); + }; + + const handleCLose = () => { + setNewParticipants([]); + onClose(); + }; + + const onAutocompleteChange = (_e, value: any) => { + const lastIdx = value.length - 1; + + // If we have a string, it means that we don't have the number as a contact. + if (typeof value[lastIdx] === 'string') { + value.splice(lastIdx, 1, { number: value[lastIdx] }); + setNewParticipants(value); + return; + } + // If we have the contact + setNewParticipants(value as any[]); + }; + + const renderAutocompleteInput = (params) => ( + { + if (e.key === 'Enter' && e.currentTarget.value) { + e.preventDefault(); + onAutocompleteChange(e, [...newParticipants, e.currentTarget.value]); + } + }, + autoFocus: true, + }} + /> + ); + + const isYourself = newParticipants.find((p) => p.number === myPhoneNumber); + const disableSubmit = !newParticipants?.length || !!isYourself; + + return ( + + + } + multiple + autoHighlight + options={allowedContact || []} + ListboxProps={{ style: { marginLeft: 10 } }} + getOptionLabel={(contact) => contact.display || contact.number} + onChange={onAutocompleteChange} + renderInput={renderAutocompleteInput} + /> + + + + ); +}; + +export default AddParticipantModal; diff --git a/phone/src/apps/messages/components/modal/GroupDetailsModal.tsx b/phone/src/apps/messages/components/modal/GroupDetailsModal.tsx index 3b6411607..6ad6b8a08 100644 --- a/phone/src/apps/messages/components/modal/GroupDetailsModal.tsx +++ b/phone/src/apps/messages/components/modal/GroupDetailsModal.tsx @@ -1,66 +1,253 @@ -import React from 'react'; -import Modal from '@ui/components/Modal'; -import { Box, Button, Stack, Typography } from '@mui/material'; -import PersonIcon from '@mui/icons-material/Person'; -import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import React, { useState } from 'react'; +import { + Avatar as MuiAvatar, + Button, + Paper, + Slide, + Typography, + List, + ListItem, + ListItemText, + ListItemAvatar, +} from '@mui/material'; import { findParticipants } from '../../utils/helpers'; import { useMyPhoneNumber } from '@os/simcard/hooks/useMyPhoneNumber'; import { useContactActions } from '../../../contacts/hooks/useContactActions'; +import makeStyles from '@mui/styles/makeStyles'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { MessageConversation } from '@typings/messages'; +import { SearchField } from '@ui/components/SearchField'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import GroupMemberInfo from './GroupMemberInfo'; +import AddParticipantModal from './AddParticipantModal'; +import Backdrop from '@ui/components/Backdrop'; +import { useMessageAPI } from '../../hooks/useMessageAPI'; + +const useStyles = makeStyles((theme) => ({ + root: { + zIndex: 20, + height: '100%', + width: '100%', + position: 'absolute', + background: theme.palette.background.default, + }, + groupDetails: { + width: '75%', + margin: '0 auto', + textAlign: 'center', + marginBottom: 15, + }, + participantList: { + margin: '0 auto', + textAlign: 'center', + }, + buttons: { + margin: '0 auto', + display: 'flex', + justifyContent: 'center', + gap: 5, + marginTop: 5, + }, + avatar: { + margin: 'auto', + height: '100px', + width: '100px', + marginBottom: 10, + }, +})); interface GroupDetailsModalProps { open: boolean; onClose: () => void; - conversationList: string; - addContact: (number: any) => void; + conversation: MessageConversation; + removeMember: (number: string) => void; + leaveGroup: () => void; + addContact: (number: string) => void; + makeOwner: (number: string) => void; } const GroupDetailsModal: React.FC = ({ open, onClose, - conversationList, + conversation, + leaveGroup, + removeMember, addContact, + makeOwner, }) => { + const classes = useStyles(); + + const groupAmount = conversation.conversationList.split('+').length; const myPhoneNumber = useMyPhoneNumber(); const { getContactByNumber } = useContactActions(); + const { addGroupMembers } = useMessageAPI(); + const [inputVal, setInputVal] = useState(''); + + const [participants, setParticipants] = useState( + findParticipants(conversation.conversationList, myPhoneNumber), + ); + + const updateSearch = (e: string) => { + setInputVal(e); + setFilterVal(e); + }; - const participants = findParticipants(conversationList, myPhoneNumber); + const setFilterVal = (filterValue: string) => { + if (!filterValue) + return setParticipants(findParticipants(conversation.conversationList, myPhoneNumber)); + const searchRegex = new RegExp(filterValue, 'gi'); + const filteredParticipants = participants.filter((participant) => { + const contact = getContactByNumber(participant); + return participant.match(searchRegex) || contact?.display.match(searchRegex); + }); + setParticipants(filteredParticipants); + }; - const findContact = (phoneNumber: string) => { - return getContactByNumber(phoneNumber); + const removeGroupMember = (number: string) => { + removeMember(number); + setParticipants(participants.filter((participant) => participant !== number)); }; - const handleAddContact = (participant: string) => { - addContact(participant); + const handleAddGroupMembers = (selectedParticipants: string[]) => { + setParticipants([...participants, ...selectedParticipants]); + addGroupMembers( + conversation.id, + selectedParticipants, + myPhoneNumber, + conversation.conversationList, + ); }; + const closeGroupSettings = () => { + setInputVal(null); + setSelectedMember(''); + setIsOptionsModalOpen(false); + onClose(); + }; + + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + + const [selectedMember, setSelectedMember] = useState(''); + + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + const selectMember = (member: string) => { + setSelectedMember(member); + setIsOptionsModalOpen(true); + }; + + const closeOptionsModal = () => { + setIsOptionsModalOpen(false); + }; + + const isGroupOwner = conversation.owner === myPhoneNumber; + return ( - - - - Details - {/**/} - - - {participants.map((participant) => { - const contact = findContact(participant); - - return ( - - - - - {contact?.display ?? participant} - - {!contact && ( - - )} - - - ); - })} - + + + setIsAddModalOpen(!isAddModalOpen)} + participants={participants} + myPhoneNumber={myPhoneNumber} + handleAddGroupMembers={handleAddGroupMembers} + /> + {isAddModalOpen && } + + + +
+ + {conversation.label} + + Group ยท {groupAmount} Members + +
+ updateSearch(e.target.value)} + placeholder={'Search..'} + /> +
+ + {!inputVal && ( + + + + + + + )} + {participants.map((participant) => { + const contact = getContactByNumber(participant); + return ( + + + {contact?.avatar ? ( + + ) : ( + {contact?.display.slice(0, 1).toUpperCase()} + )} + + + + + + ); + })} + +
+
+ {isGroupOwner && ( + + )} + +
+
+
); }; diff --git a/phone/src/apps/messages/components/modal/GroupMemberInfo.tsx b/phone/src/apps/messages/components/modal/GroupMemberInfo.tsx new file mode 100644 index 000000000..bdc99d796 --- /dev/null +++ b/phone/src/apps/messages/components/modal/GroupMemberInfo.tsx @@ -0,0 +1,95 @@ +import React, { useMemo, useCallback } from 'react'; +import Person from '@mui/icons-material/Person'; +import PersonRemoveIcon from '@mui/icons-material/PersonRemove'; +import { ContextMenu, IContextMenuOption } from '@ui/components/ContextMenu'; +import SupervisorAccountIcon from '@mui/icons-material/SupervisorAccount'; +import { useContactActions } from '../../../contacts/hooks/useContactActions'; +import { MessageConversation } from '@typings/messages'; +import { useMyPhoneNumber } from '@os/simcard/hooks/useMyPhoneNumber'; +import { useHistory, useLocation } from 'react-router-dom'; +import { useCall } from '@os/call/hooks/useCall'; +import PhoneIcon from '@mui/icons-material/Phone'; +import ChatIcon from '@mui/icons-material/Chat'; + +interface GroupDetailsModalProps { + open: boolean; + onClose: () => void; + participant: string; + removeMember: (number: string) => void; + conversation: MessageConversation; + addContact: (number: string) => void; + makeOwner: (number: string) => void; +} + +const GroupMemberInfo: React.FC = ({ + open, + onClose, + participant, + removeMember, + conversation, + addContact, + makeOwner, +}) => { + const { getContactByNumber } = useContactActions(); + const { pathname } = useLocation(); + const history = useHistory(); + const { initializeCall } = useCall(); + + const myPhoneNumber = useMyPhoneNumber(); + const isGroupOwner = conversation.owner === myPhoneNumber; + const contact = getContactByNumber(participant); + + const messageContact = useCallback( + (number: string) => { + const referal = encodeURIComponent(pathname); + return history.push(`/messages/new?phoneNumber=${number}&referal=${referal}`); + }, + [history, pathname], + ); + + const menuOptions: IContextMenuOption[] = useMemo( + () => [ + { + label: 'Make Group Owner', + icon: , + onClick: () => makeOwner(participant), + hide: !isGroupOwner, + }, + { + label: contact ? 'Manage Contact' : 'Add Contact', + icon: , + onClick: () => addContact(participant), + }, + { + label: 'Message', + icon: , + onClick: () => messageContact(participant), + }, + { + label: 'Call', + icon: , + onClick: () => initializeCall(participant), + }, + { + label: 'Remove from Group', + icon: , + onClick: () => removeMember(participant), + hide: !isGroupOwner, + }, + ], + [ + isGroupOwner, + contact, + makeOwner, + participant, + addContact, + messageContact, + initializeCall, + removeMember, + ], + ); + + return ; +}; + +export default GroupMemberInfo; diff --git a/phone/src/apps/messages/components/modal/MessageBubble.tsx b/phone/src/apps/messages/components/modal/MessageBubble.tsx index 76b074bd0..4f43b8e1f 100644 --- a/phone/src/apps/messages/components/modal/MessageBubble.tsx +++ b/phone/src/apps/messages/components/modal/MessageBubble.tsx @@ -11,6 +11,7 @@ import MessageBubbleMenu from './MessageBubbleMenu'; import { useSetSelectedMessage } from '../../hooks/state'; import MessageEmbed from '../ui/MessageEmbed'; import { useContactActions } from '../../../contacts/hooks/useContactActions'; +import SystemMessage from '../ui/SystemMessage'; import dayjs from 'dayjs'; const useStyles = makeStyles((theme) => ({ @@ -40,6 +41,19 @@ const useStyles = makeStyles((theme) => ({ borderRadius: '15px', textOverflow: 'ellipsis', }, + system: { + float: 'left', + padding: '1px 12px', + width: 'auto', + marginLeft: 5, + maxWidth: '80%', + height: 'auto', + background: theme.palette.background.default, + color: theme.palette.text.secondary, + borderRadius: '8px', + display: 'flex', + justifyContent: 'center', + }, myAudioSms: { float: 'right', margin: theme.spacing(1), @@ -142,45 +156,54 @@ export const MessageBubble: React.FC = ({ message }) => { display="flex" ml={1} alignItems="stretch" - justifyContent={isMine ? 'flex-end' : 'flex-start'} + justifyContent={message.is_system ? 'center' : isMine ? 'flex-end' : 'flex-start'} mt={1} > - {!isMine ? : null} - - {message.is_embed ? ( + {!message.is_system && <>{!isMine ? : null}} + + + {message.is_system && } + {!message.is_system && ( <> - - - ) : ( - - {isMessageImage ? ( - - - + {message.is_embed ? ( + <> + + ) : ( - <>{message.message} + + {isMessageImage ? ( + + + + ) : ( + <>{message.message} + )} + {showVertIcon && ( + + + + )} + )} - {showVertIcon && ( - - - + {!isMine && ( + + {getContact()?.display ?? message.author} + )} - - )} - {!isMine && ( - - {getContact()?.display ?? message.author} - + + {dayjs.unix(message.createdAt).fromNow()} + + )} - - {dayjs.unix(message.createdAt).fromNow()} - { const { fetchMessages } = useMessageAPI(); const { getLabelOrContact, getConversationParticipant } = useMessageActions(); const { initializeCall } = useCall(); + const { removeGroupMember, leaveGroup, makeGroupOwner } = useMessageAPI(); const { getContactByNumber } = useContactActions(); const [messages, setMessages] = useMessagesState(); const [isLoaded, setLoaded] = useState(false); - const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [isGroupSettingsModalOpen, setIsGroupSettingsModalOpen] = useState(false); const { ResourceConfig } = usePhone(); @@ -104,12 +105,12 @@ export const MessageModal = () => { history.push('/messages'); }; - const openGroupModal = () => { - setIsGroupModalOpen(true); + const openGroupSettingsModal = () => { + setIsGroupSettingsModalOpen(true); }; - const closeGroupModal = () => { - setIsGroupModalOpen(false); + const closeGroupSettingsModal = () => { + setIsGroupSettingsModalOpen(false); }; useEffect(() => { @@ -152,6 +153,26 @@ export const MessageModal = () => { return history.push(`/contacts/-1/?addNumber=${number}&referal=${referal}`); }; + const handleGroupRemove = (number: string) => { + removeGroupMember( + activeMessageConversation.conversationList, + activeMessageConversation.id, + number, + ); + }; + + const handleMakeGroupOwner = (number: string) => { + makeGroupOwner(activeMessageConversation.id, number); + }; + + const handleLeaveGroup = () => { + leaveGroup( + activeMessageConversation.conversationList, + activeMessageConversation.id, + myPhoneNumber, + ); + }; + // This only gets used for 1 on 1 conversations let conversationList = activeMessageConversation.conversationList.split('+'); conversationList = conversationList.filter((targetNumber) => targetNumber !== myPhoneNumber); @@ -174,12 +195,14 @@ export const MessageModal = () => { }} > - {isGroupModalOpen && } { )} {activeMessageConversation.isGroupChat ? ( ) : !activeMessageConversation.isGroupChat && !doesContactExist ? (