<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
- <list default="true" id="ab92709f-5288-4a55-873b-517f6adb96d1" name="Changes" comment="Adding proper file upload management to chat #PHO-2." />
+ <list default="true" id="ab92709f-5288-4a55-873b-517f6adb96d1" name="Changes" comment="Adjusting gitignore file.">
+ <change afterPath="$PROJECT_DIR$/src/app/services/Photos/photoService.js" afterDir="false" />
+ <change afterPath="$PROJECT_DIR$/src/app/services/index.js" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/run-home.sh" beforeDir="false" afterPath="$PROJECT_DIR$/run-home.sh" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/src/app/components/ChatBubble.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/components/ChatBubble.jsx" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/src/app/views/Chat/Chat.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/views/Chat/Chat.jsx" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/src/app/views/Login/Login.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/views/Login/Login.jsx" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/src/app/views/Posts/Posts.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/views/Posts/Posts.jsx" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/src/index.css" beforeDir="false" afterPath="$PROJECT_DIR$/src/index.css" afterDir="false" />
+ </list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
"javascript.nodejs.core.library.configured.version": "20.12.1",
"javascript.nodejs.core.library.typings.version": "20.12.14",
"junie.onboarding.icon.badge.shown": "true",
- "last_opened_file_path": "/Volumes/ServerShare/deploy/phs-stack/volumes/phs-home",
+ "last_opened_file_path": "/Volumes/ServerShare/deploy/phs-stack/volumes/phs-home/src/app/services",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
+ <recent name="$PROJECT_DIR$/src/app/services" />
<recent name="$PROJECT_DIR$/src/app/components" />
<recent name="$PROJECT_DIR$/src" />
<recent name="$PROJECT_DIR$/public" />
<recent name="$PROJECT_DIR$" />
- <recent name="$PROJECT_DIR$/src/app/views" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/app/views/Posts" />
<workItem from="1758924501796" duration="1314000" />
<workItem from="1758938211300" duration="3162000" />
<workItem from="1758942723819" duration="7654000" />
+ <workItem from="1758977841303" duration="29508000" />
</task>
<task id="LOCAL-00001" summary="Initial commit.">
<option name="closed" value="true" />
<option name="project" value="LOCAL" />
<updated>1758950414723</updated>
</task>
- <option name="localTasksCounter" value="10" />
+ <task id="LOCAL-00010" summary="Adjusting gitignore file.">
+ <option name="closed" value="true" />
+ <created>1758977897975</created>
+ <option name="number" value="00010" />
+ <option name="presentableId" value="LOCAL-00010" />
+ <option name="project" value="LOCAL" />
+ <updated>1758977897975</updated>
+ </task>
+ <option name="localTasksCounter" value="11" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
+ <component name="Vcs.Log.Tabs.Properties">
+ <option name="TAB_STATES">
+ <map>
+ <entry key="MAIN">
+ <value>
+ <State />
+ </value>
+ </entry>
+ </map>
+ </option>
+ </component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Initial commit." />
<MESSAGE value="changing the run file name." />
<MESSAGE value="Rearranging files to their proper directory structure #PHO-1." />
<MESSAGE value="Adding proper file upload management to chat #PHO-1." />
<MESSAGE value="Adding proper file upload management to chat #PHO-2." />
- <option name="LAST_COMMIT_MESSAGE" value="Adding proper file upload management to chat #PHO-2." />
+ <MESSAGE value="Adjusting gitignore file." />
+ <option name="LAST_COMMIT_MESSAGE" value="Adjusting gitignore file." />
</component>
<component name="ai.zencoder.plugin.mcp">
<option name="internalToolsState" value="{"fetch_webpage":true,"web_search":true,"ExecuteShellCommand":true,"file_search":true,"str_replace_editor":true,"list_resources":true,"FileDiagnosticsTool":true,"fulltext_search":true,"read_resource":true,"RequirementsTool":true}" />
</component>
<component name="ai.zencoder.plugin.rag.RepoInfoWarningState">
<option name="currentWarningState" value="true" />
- <option name="lastCheckTime" value="1758938305650" />
+ <option name="lastCheckTime" value="1759024975282" />
</component>
</project>
\ No newline at end of file
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.12.2",
+ "dompurify": "^3.2.7",
"framer-motion": "^12.23.12",
"lodash": "^4.17.21",
"material-react-table": "^3.2.1",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
+ "node_modules/dompurify": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+ "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.12.2",
+ "dompurify": "^3.2.7",
"framer-motion": "^12.23.12",
"lodash": "^4.17.21",
"material-react-table": "^3.2.1",
import { API_BASE_URL } from '../../App';
import React, { useEffect, useRef, useState } from 'react';
import axios from 'axios';
-import { Card, CardActions, CardContent, Menu, MenuItem } from '@mui/material';
+import { Card, CardActions, CardContent, CardMedia, Menu, MenuItem } from '@mui/material';
+import { PhotoService } from '../services';
/**
* @typedef {Object} Message
};
}, [] );
+ const getPreview = ( data ) => {
+ return new PhotoService().getSanitizedUrl( data.file_data, data.mimetype );
+ };
+
return (
<>
<Menu anchorEl={ cardRef?.current }
{ Array.isArray( msg.attached_files ) && msg.attached_files.length > 0 && (
<div className="mt-2">
{ msg.attached_files.map( f => (
- <a key={ f.file_id }
- href={ `${ API_BASE_URL }/file/${ f.file_id }/view` }
- download
- className="text-blue-300 hover:underline">
- Attachment
- </a>
+ <Card key={f.id} sx={{ width: 100, position: 'relative' }}>
+ {f.file_type === 'image' ? (
+ <CardMedia component="img" height="100" image={ getPreview(f) } alt="preview" />
+ ) : (
+ <CardMedia
+ component="video"
+ height="100"
+ src={ getPreview(f) }
+ controls
+ sx={{ objectFit: 'cover' }}
+ />
+ )}
+ </Card>
) ) }
</div>
) }
);
};
+/**@namespace data.file_data*/
+/**@namespace data.mimetype*/
+
export default ChatBubble;
\ No newline at end of file
--- /dev/null
+import { v4 as uuid } from 'uuid';
+import DOMPurify from 'dompurify';
+/**
+ *
+ * @typedef {{Object}} Photo
+ * From backend
+ * @property {number} id = 0
+ * @property {string} photo_data | null = null
+ * @property {string} photo_type = ''
+ * @property {string||Date} created_at:string = ''
+ *
+ * Additional fields
+ * @property {string} base64 = ''
+ * @property {string} base64_sz = ''
+ * @property {string} file_type = ''
+ * @property {string} file_name = ''
+ * @property {boolean} loaded = false
+ * @property {boolean} uploaded = true
+ * @property {boolean} uploading = false
+ * @property {string} uuid = ''
+ *
+ * @typedef {{targetWidth:number,targetHeight:number}} Dimensions
+ */
+
+class PhotoService {
+
+ static b64toBlob(b64Data, contentType = '', sliceSize = 512) {
+ const byteCharacters = atob(b64Data);
+ const byteArrays = [];
+
+ for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+ const slice = byteCharacters.slice(offset, offset + sliceSize);
+
+ const byteNumbers = new Array(slice.length);
+ for (let i = 0; i < slice.length; i++) {
+ byteNumbers[i] = slice.charCodeAt(i);
+ }
+
+ const byteArray = new Uint8Array(byteNumbers);
+
+ byteArrays.push(byteArray);
+ }
+
+ return new Blob(byteArrays, {type: contentType});
+ }
+
+ /**
+ *
+ * @param {File} fileImage
+ * @returns {Promise<string>}
+ */
+ static firstFileToBase64(fileImage) {
+ return new Promise((resolve, reject) => {
+ const fileReader = new FileReader();
+ if (fileReader && fileImage != null) {
+ fileReader.readAsDataURL(fileImage);
+ fileReader.onload = () => {
+ resolve(fileReader.result);
+ };
+
+ fileReader.onerror = (error) => {
+ reject(error);
+ };
+ } else {
+ reject(new Error('No file found'));
+ }
+ });
+ }
+
+ /**
+ *
+ * @param {string} base64
+ * @param {string} type
+ * @returns {string}
+ */
+ getSanitizedUrl(base64, type = 'png') {
+ // Validate base64 string
+ if (!base64 || typeof base64 !== 'string' || base64.trim() === '') {
+ throw new Error('Invalid or empty base64 string');
+ }
+
+ // Ensure the base64 string doesn't already include the data URI scheme
+ let formattedBase64 = base64.trim();
+ if (formattedBase64.startsWith('data:image')) {
+ // If it already has a data URI scheme, use it directly
+ formattedBase64 = base64;
+ } else {
+ // Add the data URI scheme with the specified image type
+ formattedBase64 = `data:image/${type};base64,${formattedBase64}`;
+ }
+
+ // Sanitize the data URL
+ return DOMPurify.sanitize(formattedBase64, {
+ ALLOWED_URI_REGEXP: /^data:image\/(jpeg|png|gif|webp);base64,/i,
+ });
+
+ // return this.sanitizer.sanitize(SecurityContext.URL, this.sanitizer.bypassSecurityTrustResourceUrl(url));
+ }
+
+ /**
+ *
+ * @param {File} image
+ * @param {number||string} user_id
+ * @param {any} maxWidth
+ * @param {any} maxHeight
+ * @returns {Promise<Photo>}
+ *
+ **/
+ getPhotoFromImageFile(image, user_id, maxWidth = 640 * 2, maxHeight =480 * 2) {
+ return new Promise((resolve, reject) => {
+ let pobj;
+ PhotoService.firstFileToBase64(image)
+ .then((photo_base64) => {
+ const safeUrl = this.getSanitizedUrl(photo_base64);
+ if (safeUrl) {
+ pobj = {
+ id: 0, uuid: uuid(), base64_sz: safeUrl, file_type: image.type, file_name: image.name, user_id,
+ created_at: '', loaded: false, uploaded: false, photo_data: null, photo_type: '', uploading: false
+ };
+ return this.resizeImage(pobj, maxWidth, maxHeight);
+ } else {
+ reject('safeUrl is null');
+ return null;
+ }
+ })
+ .then((url) => {
+ pobj.base64_sz = url;
+ resolve(pobj);
+ })
+ .catch((err) => {
+ reject(err);
+ });
+ });
+ }
+
+ /**
+ *
+ * @param {Photo} image
+ * @param {any} maxWidth
+ * @param {any} maxHeight
+ * @returns {Promise<any>}
+ */
+ resizeImage(image, maxWidth, maxHeight) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.src = image.base64_sz;
+ img.onload = () => {
+ const width = img.width;
+ const height = img.height;
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ const dim = this.getTargetDimensions(width, height, maxWidth, maxHeight);
+ canvas.width = dim.targetWidth;
+ canvas.height = dim.targetHeight;
+
+ if (ctx) {
+ // Draw img into canvas
+ ctx.drawImage(img, 0, 0, dim.targetWidth, dim.targetHeight);
+ const url = canvas.toDataURL();
+ resolve(url);
+ }
+ else {
+ reject('ctx is null');
+ }
+ };
+ img.onerror = error => reject(error);
+ });
+ }
+
+ /**
+ *
+ * @param {any} width
+ * @param {any} height
+ * @param {any} maxWidth
+ * @param {any} maxHeight
+ */
+ getTargetDimensions(width, height, maxWidth, maxHeight) {
+ let ratio = 0;
+ let targetWidth = width;
+ let targetHeight = height;
+ if (width > maxWidth) {
+ ratio = maxWidth / width;
+ targetWidth = maxWidth;
+ targetHeight = height * ratio;
+ height = height * ratio;
+ width = width * ratio;
+ }
+ if (height > maxHeight) {
+ ratio = maxHeight / height;
+ targetHeight = maxHeight;
+ targetWidth = width * ratio;
+ }
+ return {targetWidth, targetHeight};
+ };
+
+ static instance = new PhotoService();
+}
+
+export default PhotoService;
\ No newline at end of file
--- /dev/null
+import PhotoService from './Photos/photoService';
+
+export { PhotoService };
\ No newline at end of file
CardContent,
CardHeader,
Grid,
- IconButton,
InputAdornment,
TextField,
Box,
CardMedia,
Chip,
- LinearProgress
+ LinearProgress, Stack
} from '@mui/material';
import ChatBubble from '../../components/ChatBubble';
+import { PhotoService } from '../../services';
/**
* @typedef {Object} User
* @property {string} last_name
*/
+/**
+ * @typedef {Object} MediaFile
+ * @property {File} file - The selected file (image or video)
+ * @property {'image' | 'video'} type - The media type
+ */
+
/**
* Chat component
* @param {Object} props - Component props
const [selectedConversation, setSelectedConversation] = useState( null );
const [messages, setMessages] = useState( [] );
const [content, setContent] = useState( '' );
- const [attachedFiles, setAttachedFiles] = useState( [] );
const [users, setUsers] = useState( [] );
const [groups, setGroups] = useState( [] );
const [error, setError] = useState( null );
if (user) {
const fetchData = async () => {
try {
- const [usersRes, groupsRes, messagesRes] = await Promise.all( [
+ const [usersRes, groupsRes, sentMessagesRes, recMessageRes] = await Promise.all( [
axios.get( `${ API_BASE_URL }/user`, { withCredentials:true } ),
axios.get( `${ API_BASE_URL }/message_group`, { withCredentials:true } ),
- axios.get( `${ API_BASE_URL }/message`, { withCredentials:true } )
+ axios.get( `${ API_BASE_URL }/message`, { params: { sender_id: user.id }, withCredentials:true } ),
+ axios.get( `${ API_BASE_URL }/message`, { params: { recipient_id: user.id }, withCredentials:true } )
] );
setUsers( Array.isArray( usersRes.data ) ? usersRes.data.filter( u => u.id !== user.id ) : [] );
setGroups( Array.isArray( groupsRes.data ) ? groupsRes.data : [] );
// Group messages by recipient_id or group_id
- const messages = Array.isArray( messagesRes.data ) ? messagesRes.data : [];
+ const sentMessages = Array.isArray( sentMessagesRes.data ) ? sentMessagesRes.data : [];
+ const recMessages = Array.isArray( recMessageRes.data ) ? recMessageRes.data : [];
+ const messages = [...sentMessages, ...recMessages].sort( (a,b) => new Date(b.created_at) > new Date(a.created_at) ? -1 : 0);
const convoMap = {};
messages.forEach( msg => {
if (msg.group_id) {
}, [user] );
useEffect( () => {
- messagesEndRef.current?.scrollIntoView( { behavior:'smooth' } );
- }, [messages] );
+ if ( selectedConversation && messages && messagesEndRef.current ) {
+ messagesEndRef.current.scrollIntoView( { behavior:'smooth', block: 'end', inline: 'nearest' } );
+ }
+ }, [messages, selectedConversation, messagesEndRef.current] );
/**
* Select a conversation to view messages
* Send a message with optional file attachments
*/
const sendMessage = async () => {
- if (content.trim() || attachedFiles.length) {
+ if (content.trim() || selectedMedia.length) {
let fileIds = [];
try {
- if (attachedFiles.length) {
+ if (selectedMedia.length) {
const formData = new FormData();
- attachedFiles.forEach( f => formData.append( 'files', f ) );
+ await Promise.all(selectedMedia.map( async f => {
+ const file = await new PhotoService().getPhotoFromImageFile(f.file, user?.id);
+ const base64Image = file.base64_sz.split(';base64,').pop();
+ const blob = PhotoService.b64toBlob(base64Image, file.file_type);
+ return formData.append('files', blob);
+ } ));
formData.append( 'visibility', selectedConversation?.type === 'group' ? 'family' : selectedConversation?.type === 'user' ? 'specific' : 'family' );
if (selectedConversation?.type === 'user') formData.append( 'specific_users', JSON.stringify( [selectedConversation.targetId] ) );
const res = await axios.post( `${ API_BASE_URL }/file/create`, formData, {
headers:{ 'Content-Type':'multipart/form-data' },
- withCredentials:true
+ withCredentials:true,
+ onUploadProgress: progressEvent => setProgress( Math.round( ( progressEvent.loaded * 100 ) / progressEvent.total ) )
} );
fileIds = Array.isArray( res.data ) ? res.data.map( f => f.id ) : [res.data.id];
}
fileIds
} );
setContent( '' );
- setAttachedFiles( [] );
+ setSelectedMedia( [] );
+ setPreviews( [] );
} catch (err) {
console.error( 'Send message error:', err );
setError( 'Failed to send message' );
const newPreviews = validMedia.map((media) => URL.createObjectURL(media.file));
setPreviews(newPreviews);
- // Trigger upload if provided
- // if (onUpload && validMedia.length > 0) {
- // uploadFiles(validMedia.map((media) => media.file));
- // }
};
/**
});
};
+ const getChatActionClasses = () => {
+ const classes = ['chat-action'];
+ if (previews.length > 0) {
+ classes.push('previews');
+ }
+ return classes.join(' ');
+ };
+
+ const getChatContentClasses = () => {
+ const classes = ['chat-content'];
+ if (previews.length > 0) {
+ classes.push('previews');
+ }
+ return classes.join(' ');
+ };
+
+ /**
+ *
+ * @returns {React.ReactNode[]}
+ */
+ const getPreviews = () => {
+ return previews.map((preview, index) => (
+ <Card key={index} sx={{ width: 100, position: 'relative' }}>
+ {selectedMedia[index].type === 'image' ? (
+ <CardMedia component="img" height="100" image={preview} alt="preview" />
+ ) : (
+ <CardMedia
+ component="video"
+ height="100"
+ src={preview}
+ controls
+ sx={{ objectFit: 'cover' }}
+ />
+ )}
+ <Chip
+ label="Remove"
+ size="small"
+ onClick={() => removeFile(index)}
+ sx={{ position: 'absolute', top: 4, right: 4 }}
+ />
+ </Card>
+ ));
+ }
+
return (
<Grid container
className="page-container"
spacing={ 0 }>
+ {/* Progress Bar */}
+ {progress > 0 && (
+ <LinearProgress variant="determinate" value={progress} sx={{ mt: 1 }} />
+ )}
+ <input type="file"
+ accept="image/*,video/*"
+ multiple
+ hidden
+ ref={fileRef}
+ onChange={ e => handleFileChange(e) }/>
{ isSidebarOpen && <Grid size={{ xs: 10, md: 6, lg: 3 }}>
{ getConvos() }
</Grid> }
className="text-blue-600">☰
</button>
</div> }/>
- <CardContent className="chat-content">
- <div className="flex-1 overflow-y-auto p-4 bg-gray-50">
- { selectedConversation ? (
- messages.length === 0 ? (
- <p className="text-center text-gray-500">No messages in this conversation</p>
- ) : (
- <Grid container
- columnSpacing={ 0 }
- rowSpacing={ 2 }
- maxWidth="xxl">
- { getMessages() }
- </Grid>
- )
- ) : (
- <p className="text-center text-gray-500">Select a conversation to start chatting</p>
- ) }
- <div ref={ messagesEndRef }/>
- </div>
+ <CardContent className={ getChatContentClasses() }>
+ <Grid container className='chat-content overflow-scroll'
+ columnSpacing={ 0 }
+ rowSpacing={ 2 }
+ maxWidth="xxl">
+ { selectedConversation && messages?.length ? getMessages() : null }
- {/* Progress Bar */}
- {progress > 0 && (
- <LinearProgress variant="determinate" value={progress} sx={{ mt: 1 }} />
- )}
- { selectedConversation && previews.length > 0 && (
- <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 2, width: '100vw' }}>
- {previews.map((preview, index) => (
- <Card key={index} sx={{ width: 150, position: 'relative' }}>
- {selectedMedia[index].type === 'image' ? (
- <CardMedia component="img" height="150" image={preview} alt="preview" />
- ) : (
- <CardMedia
- component="video"
- height="150"
- src={preview}
- controls
- sx={{ objectFit: 'cover' }}
- />
- )}
- <Chip
- label="Remove"
- size="small"
- onClick={() => removeFile(index)}
- sx={{ position: 'absolute', top: 4, right: 4 }}
- />
- </Card>
- ))}
- </Box>
- ) }
+ <Grid size={{ xs: 12}} sx={{ minHeight: '2rem'}} ref={ messagesEndRef }/>
+ </Grid>
</CardContent>
- <CardActions className="chat-actions">
- <input type="file"
- accept="image/*,video/*"
- multiple
- hidden
- ref={fileRef}
- onChange={ e => handleFileChange(e) }
- className="mb-2"/>
- { selectedConversation ? (
- <TextField value={ content }
- className='chat-input'
- onChange={ e => setContent( e.target.value ) }
- placeholder="Type a message"
- fullWidth
- slotProps={ {
- input:{
- startAdornment:
- <InputAdornment
- position="start">
- <Button
- variant='contained'
- onClick={ () => {
- console.log(fileRef);
- fileRef?.current?.click();
- }}
- size="small"
- color="primary"><AddIcon/></Button>
- </InputAdornment>,
- endAdornment:
- <InputAdornment
- variant="filled"
- position="end">
- <Button
- variant="contained"
- color="primary"
- onClick={sendMessage}
- size="small">Send</Button>
- </InputAdornment>
- }
- } }/>
-
- /*<div className="p-4 border-t">
- <input type="file"
- accept="image/!*,video/!*"
- multiple
- onChange={ e => setAttachedFiles( Array.from( e.target.files ) ) }
- className="mb-2"/>
- <div className="flex">
- <input value={ content }
- onChange={ e => setContent( e.target.value ) }
- placeholder="Type a message"
- className="border p-2 flex-grow rounded-l"/>
- <button onClick={ sendMessage }
- className="bg-blue-500 text-white p-2 rounded-r">Send
- </button>
- </div>
- </div>*/
- ) : null }
+ <CardActions className={ getChatActionClasses() }>
+ <Stack className='w-full'>
+ { selectedConversation ? <Grid size={ { xs:12 } } height='3.2rem'>
+ <TextField value={ content }
+ className='chat-input'
+ onChange={ e => setContent( e.target.value ) }
+ placeholder="Type a message"
+ fullWidth
+ slotProps={ {
+ input:{
+ startAdornment:
+ <InputAdornment
+ position="start">
+ <Button
+ variant='contained'
+ onClick={ () => {
+ fileRef?.current?.click();
+ }}
+ size="small"
+ color="primary"><AddIcon/></Button>
+ </InputAdornment>,
+ endAdornment:
+ <InputAdornment
+ variant="filled"
+ position="end">
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={sendMessage}
+ size="small">Send</Button>
+ </InputAdornment>
+ }
+ } }/>
+ </Grid> : null }
+ { selectedConversation && previews.length > 0 ? (
+ <Grid size={{ xs: 12 }} height='100' >
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, width: '100vw' }}>
+ { getPreviews() }
+ </Box>
+ </Grid>
+ ) : null }
+ </Stack>
</CardActions>
</Card>
</Grid>
padding: 0 !important;
}
+.chat-actions .previews {
+ height: calc(3.2rem + 150px);
+}
+
.chat-content {
height: calc(100vh - 8rem);
- overflow-y: scroll;
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+}
+
+.chat-content.previews {
+ height: calc(100vh - 8rem - 150px);
}
.chat-input {