]> PHS Git Server - phs-home.git/commitdiff
Added modern attachment interaction for chat interface #PHO-2.
authorcharleswrayjr <charleswrayjr@gmail.com>
Mon, 29 Sep 2025 04:46:10 +0000 (23:46 -0500)
committercharleswrayjr <charleswrayjr@gmail.com>
Mon, 29 Sep 2025 04:46:10 +0000 (23:46 -0500)
.idea/workspace.xml
package-lock.json
package.json
src/app/components/ChatBubble.jsx
src/app/services/Photos/photoService.js [new file with mode: 0755]
src/app/services/index.js [new file with mode: 0755]
src/app/views/Chat/Chat.jsx
src/index.css

index f2e4138cf89e154468e57ab0e28c0bb1bb9f10d7..7697d555bfea860d6f9711e70e04c6c9fe7384f2 100755 (executable)
@@ -4,7 +4,19 @@
     <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" />
@@ -37,7 +49,7 @@
     "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="{&quot;fetch_webpage&quot;:true,&quot;web_search&quot;:true,&quot;ExecuteShellCommand&quot;:true,&quot;file_search&quot;:true,&quot;str_replace_editor&quot;:true,&quot;list_resources&quot;:true,&quot;FileDiagnosticsTool&quot;:true,&quot;fulltext_search&quot;:true,&quot;read_resource&quot;:true,&quot;RequirementsTool&quot;: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
index 456af7a4efca9ea1378158588b9761ffa300c73a..2ded5f4e23dfe0389ec87599b89d6223e7df0710 100755 (executable)
@@ -20,6 +20,7 @@
         "@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",
index 12e70e81de82e23dd9409229a6046103289a0627..ec4785973843a2370b0d410579cbb240efd67f93 100755 (executable)
@@ -17,6 +17,7 @@
     "@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",
index 09cac8bf75d0ed3ba6791c8277d11e64df25f6af..d60bad38235559b0b50dd80ba10c1053cabd0956 100755 (executable)
@@ -1,7 +1,8 @@
 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
@@ -72,6 +73,10 @@ const ChatBubble = ( { user, msg, socket, setError } ) => {
     };
   }, [] );
 
+  const getPreview = ( data ) => {
+    return new PhotoService().getSanitizedUrl( data.file_data, data.mimetype );
+  };
+
   return (
     <>
       <Menu anchorEl={ cardRef?.current }
@@ -101,12 +106,19 @@ const ChatBubble = ( { user, msg, socket, setError } ) => {
           { 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>
           ) }
@@ -134,4 +146,7 @@ const ChatBubble = ( { user, msg, socket, setError } ) => {
   );
 };
 
+/**@namespace data.file_data*/
+/**@namespace data.mimetype*/
+
 export default ChatBubble;
\ No newline at end of file
diff --git a/src/app/services/Photos/photoService.js b/src/app/services/Photos/photoService.js
new file mode 100755 (executable)
index 0000000..8875f48
--- /dev/null
@@ -0,0 +1,199 @@
+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
diff --git a/src/app/services/index.js b/src/app/services/index.js
new file mode 100755 (executable)
index 0000000..db4a8df
--- /dev/null
@@ -0,0 +1,3 @@
+import PhotoService from './Photos/photoService';
+
+export { PhotoService };
\ No newline at end of file
index 43a3cf3374c3bf7088b03dbc286714b4bbe31037..3e10b50ce812e95356284509fa1db8bc1826b1df 100755 (executable)
@@ -14,15 +14,15 @@ import {
   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
@@ -32,6 +32,12 @@ import ChatBubble from '../../components/ChatBubble';
  * @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
@@ -44,7 +50,6 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
   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 );
@@ -69,16 +74,19 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
     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) {
@@ -167,8 +175,10 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
   }, [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
@@ -191,17 +201,23 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
    * 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];
         }
@@ -212,7 +228,8 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
           fileIds
         } );
         setContent( '' );
-        setAttachedFiles( [] );
+        setSelectedMedia( [] );
+        setPreviews( [] );
       } catch (err) {
         console.error( 'Send message error:', err );
         setError( 'Failed to send message' );
@@ -291,10 +308,6 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
     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));
-    // }
   };
 
   /**
@@ -311,10 +324,64 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
       });
     };
 
+  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> }
@@ -330,113 +397,58 @@ const Chat = ( { user, socket, maxSizeMB = 10 } ) => {
                                   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>
index 9098d5f58abd05084ddd165b82f7f31736b9e4a1..56bd4c80c3719fb143ca1d4c8522a0228a64e316 100755 (executable)
@@ -53,9 +53,18 @@ code {
     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 {