Creating a seamless and interactive chat interface can significantly enhance user engagement in your Power Apps solutions. In this blog post, we will walk you through the steps of building a React-based chat component using Power Apps Component Framework (PCF) with a dataset template. We’ll leverage Fluent UI for styling and functionality, ensuring a polished and user-friendly experience.

Prerequisites

Before we dive in, make sure you have the following prerequisites installed:

  1. Visual Studio Code (VSCode) or your preferred IDE
  2. Node.js (LTS version for stability).
  3. Microsoft Power Platform CLI:
    • Power Platform Tools for Visual Studio Code or Power Platform CLI for Windows.
  4. .NET Build Tools:
    • .NET Core SDK (cross-platform .NET framework) or Visual Studio with “.NET build tools” workload.

Create a new PCF project:

Open your VSCode or your preferred IDE and use the integrated terminal to run below command.

pac pcf init --namespace ChatNamespace --name ChatComponent --template dataset
cd ChatComponent
npm install @fluentui/react react react-icons

Step 1: Configuring the Manifest

In ControlManifest.Input.xml, configure your PCF control with below code:

<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="ChatNamespace" constructor="ChatComponent" version="1.0.0" display-name-key="ChatComponent" description-key="This PCFControl is a custom chat interface leveraging PCF, React, and Fluent UI to create a feature-rich chat component. The interface includes functionalities similar to WhatsApp, such as the ability to load previous chat messages dynamically." control-type="standard" >
    <external-service-usage enabled="false">
    </external-service-usage>
    <data-set name="Whatsapp_session" display-name-key="Whatsapp_session">
    </data-set>
    <resources>
      <code path="index.ts" order="1"/>
    </resources>
    <feature-usage>
      <uses-feature name="Device.pickFile" required="true" />
      <uses-feature name="Utility" required="true" />
      <uses-feature name="WebAPI" required="true" />
    </feature-usage>
  </control>
</manifest>

Note: It’s essential to update the ManifestTypes.d.ts file after adding the code by running the command npm run build. This step ensures that your PCF component correctly recognizes and utilizes the newly added property.

Step 2: Updating the index.ts file

In the index.ts file, modify the code as shown below and build the PCF control:

import { IInputs, IOutputs } from "./generated/ManifestTypes";
import { ChatDisplay } from "./ChatDisplay";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { IChatRecord, IChatDisplayProps } from "./types";

interface IMode {
    contextInfo: {
        entityId: string;
        entityTypeName:string
        // other properties as needed
    };
}

export class ChatComponent implements ComponentFramework.StandardControl<IInputs, IOutputs> {
    private container: HTMLDivElement;
    private entityId: string = '';
    private entityType: string = '';
    private datasetEntityType: string = ''
    private currentPage: number = 1; // Current page number
    private context: ComponentFramework.Context<IInputs>;
    private cumulativeRecords: IChatRecord[] = []; // Cumulative list of records
    
    constructor() {    //Methods onNextPage, onPreviousPage, and loadFirstPage are bound to this in the constructor.
        this.onNextPage = this.onNextPage.bind(this);
        // this.onPreviousPage = this.onPreviousPage.bind(this);
        this.loadFirstPage = this.loadFirstPage.bind(this);
    }

    public init(
        context: ComponentFramework.Context<IInputs>,
        notifyOutputChanged: () => void,
        state: ComponentFramework.Dictionary,
        container: HTMLDivElement
    ): void {
        this.container = container;

        const modeUnknown = context.mode as unknown;
        const entityId = (modeUnknown as IMode)?.contextInfo?.entityId;
        const entityType = (modeUnknown as IMode)?.contextInfo?.entityTypeName;

        if (entityId && entityType) {
            this.entityId = entityId;
            this.entityType = entityType
            // console.log("Entity ID retrieved:", this.entityId,"entity Type is :",entityType);
        } else {
            console.warn("Entity ID not found in contextInfo.");
        }
    }

    public updateView(context: ComponentFramework.Context<IInputs>): void {
        this.context = context;
        this.renderControl(context); // Update rendering on context change
    }

    private onNextPage = async (): Promise<void> => {
        try {
            if (this.context.parameters.Whatsapp_session.paging.hasNextPage) {
                const nextPage = this.currentPage + 1;
                this.context.parameters.Whatsapp_session.paging.loadExactPage(nextPage);
                this.currentPage = nextPage; // Update currentPage after loading
                this.renderControl(this.context);
            }
        } catch (error) {
            console.error('Error in onNextPage:', error);
        }
    };

    private loadFirstPage = async (): Promise<void> => {
        try {
            this.context.parameters.Whatsapp_session.paging.reset();
            this.currentPage = 1;
            this.cumulativeRecords = []; // Clear cumulative records on first page load
            this.renderControl(this.context);
        } catch (error) {
            console.error('loadFirstPage', error);
        }
    };
//Adding Pagination and Fetching Records including attachment id
private getFileTypeFromFileName(fileName: string): string {
    // Extract file extension
    const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
    
    // Determine file type based on extension
    switch (fileExtension) {
        case 'pdf':
            return 'application/pdf';
        case 'doc':
        case 'docx':
            return 'application/msword';
        case 'xls':
        case 'xlsx':
            return 'application/vnd.ms-excel';
        case 'ppt':
        case 'pptx':
            return 'application/vnd.ms-powerpoint';
        case 'png':
            return 'image/png';
        case 'jpg':
        case 'jpeg':
            return 'image/jpeg';
        case 'gif':
            return 'image/gif';
        case 'bmp':
            return 'image/bmp';
        case 'tiff':
            return 'image/tiff';
        case 'webp':
            return 'image/webp';
        case 'svg':
            return 'image/svg+xml';
        // Add more cases for other file types as needed
        default:
            return 'application/octet-stream'; // Default to generic binary type
    }
}


private fetchRecords = async (): Promise<IChatRecord[]> => {
    const dataset1 = this.context.parameters.Whatsapp_session;
    const paginatedRecordIds = dataset1.sortedRecordIds.slice(0, dataset1.paging.pageSize);
    const records: IChatRecord[] = [];
    
        for (const id of paginatedRecordIds) {
            const record = dataset1.records[id];
            const AttachmentId = record.getFormattedValue("dyn_attachment");
    
            // Initialize variables for attachment data
            let fileName = '';
            let fileType = '';
    
            if (AttachmentId) {
                try {
                    const fileResponse = await this.context.webAPI.retrieveRecord('dyn_wappmessage', record.getRecordId());
    
                    if (fileResponse) {
                        fileName = fileResponse.dyn_attachment_name;
                        fileType = this.getFileTypeFromFileName(fileName);
                        
                    }
                } catch (error) {
                    console.error('Error fetching attachment data:', error);
                    // Handle error as appropriate
                }
            }
    
            // Create IChatRecord object
            records.push({
                id: record.getRecordId(),
                from: record.getFormattedValue("from"),
                to: record.getFormattedValue("to"),
                description: record.getFormattedValue("description"),
                createdon: record.getFormattedValue("createdon"),
                dyn_direction: record.getFormattedValue("dyn_direction"),
                dyn_attachment: AttachmentId || null, // Use null if no attachment
                fileName: fileName,
                fileType: fileType,
                // Add other fields as needed
            });
        }
        return records;
    };
   
    private renderControl = async (context: ComponentFramework.Context<IInputs>): Promise<void> => {
            const dataset1 = context.parameters.Whatsapp_session;
        if (!dataset1.loading) {
            try {
                const newRecords = await this.fetchRecords();
                this.cumulativeRecords = [...this.cumulativeRecords, ...newRecords];
                const columns1 = dataset1.columns;
                this.datasetEntityType = dataset1.getTargetEntityType();

                const props: IChatDisplayProps = {
                    context: context,
                    columns: columns1,
                    records: this.cumulativeRecords,
                    entityId: this.entityId,
                    formEntityType: this.entityType,
                    datasetEntityType: this.datasetEntityType,
                    loadFirstPage: this.loadFirstPage,
                    hasNextPage: dataset1.paging.hasNextPage,
                    currentPage: this.currentPage,
                    onNextPage: this.onNextPage,
                    // onPreviousPage: this.onPreviousPage
                };

                ReactDOM.render(
                    React.createElement(ChatDisplay, props),
                    this.container
                );
            } catch (error) {
                console.error('Error rendering control:', error);
                // Handle error as appropriate
            }
        }
    }

    public getOutputs(): IOutputs {
        return {}; // Implement output handling if needed
    }

    public destroy(): void {
        ReactDOM.unmountComponentAtNode(this.container);
    }
}

Step 3: Defining Types

In types.ts, define the necessary types for your chat component:

import { IInputs } from "./generated/ManifestTypes";

export interface IChatRecord {
    id: string;
    from: string;
    to: string;
    description: string;
    createdon: string;
    dyn_direction: string;
    dyn_attachment: string | null;
    fileName: string;
    fileType: string;
    // Add other fields as needed
}

export interface IChatDisplayState {
    newMessage: string;
    selectedFile: File | null;
    fileName: string | '';
    fileData: string | '';
    fileTypes: string | '';
    isLoading: boolean;
    shouldScrollToBottom: boolean; // Add a flag for scrolling
}
export interface IChatDisplayProps {
    columns: ComponentFramework.PropertyHelper.DataSetApi.Column[];
    records: IChatRecord[];
    context: ComponentFramework.Context<IInputs>;
    entityId: string | undefined;
    formEntityType: string | undefined;
    datasetEntityType: string | undefined;
    hasNextPage: boolean;
    currentPage: number;
    onNextPage:() => void
    loadFirstPage:() => void
}

Step 4: Implementing the Chat Display

Create the file ChatDisplay.tsx in the PCF-Control. In ChatDisplay.tsx, implement the main chat interface:

import * as React from 'react';
import { Persona, PersonaSize } from '@fluentui/react/lib/Persona';
import { RiFileUploadLine, RiSendBackward ,RiSendToBack, RiFileDownloadFill , RiCloseLine} from 'react-icons/ri';
import { PrimaryButton } from '@fluentui/react';
import { Spinner, SpinnerSize } from '@fluentui/react';
import "./Chat.css";
import { IChatDisplayState , IChatDisplayProps } from "./types";

export class ChatDisplay extends React.Component<IChatDisplayProps, IChatDisplayState> {
    private containerRef: React.RefObject<HTMLDivElement>;
    private fileInputRef: React.RefObject<HTMLInputElement>;

    constructor(props: IChatDisplayProps) {
        super(props);
        this.state = {
            newMessage: '',
            selectedFile: null,
            fileName: '',
            fileData: '',
            fileTypes: '',
            isLoading: false,
            shouldScrollToBottom: true, // Add a flag for scrolling
        };
        this.containerRef = React.createRef<HTMLDivElement>();
        this.fileInputRef = React.createRef<HTMLInputElement>();
    }
    componentDidMount() {
        // Scroll to the bottom of the container after initial render
        this.scrollToBottom();
    }

    componentDidUpdate(prevProps: IChatDisplayProps) {
        if (prevProps.records !== this.props.records && this.state.shouldScrollToBottom) {
            this.scrollToBottom();
            this.setState({ shouldScrollToBottom: false });
        }
    }

    scrollToBottom = () => {
        if (this.containerRef.current) {
            this.containerRef.current.scrollTop = this.containerRef.current.scrollHeight;
        }
    };
    handleNextPage = () => {
            this.props.onNextPage();
            this.setState({ shouldScrollToBottom: false });
    };
    
// Function to format date as "Today", "Yesterday", or actual date
formatDate(dateString: string | null | undefined): string {
    if (!dateString) return 'N/A';

    const messageDate = new Date(dateString);
    const today = new Date();
    const yesterday = new Date(today);
    yesterday.setDate(yesterday.getDate() - 1);

    if (messageDate.toDateString() === today.toDateString()) {
        return `Today ${messageDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })}`;
    } else if (messageDate.toDateString() === yesterday.toDateString()) {
      return `Yesterday ${messageDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })}`;
    } else {
      return messageDate.toLocaleDateString('en-US', {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
        hour: 'numeric',
        minute: 'numeric'
      });
    }
  }

  handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
    this.setState({ newMessage: event.target.value });
};
handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files ? event.target.files[0] : null;
    if (file) {
        this.setState({ 
            selectedFile: file,
            fileName: file.name,
            fileTypes:file.type
        });
        this.readFileData(file);
    }
    // Reset the file input value
    if (this.fileInputRef.current) {
        this.fileInputRef.current.value = '';
    }
};
readFileData = (file: File) => {
    const reader = new FileReader();
    reader.onloadend = () => {
        const dataUrl = reader.result as string;
        const base64Content = dataUrl.split(',')[1]; // Extract base64 content
        this.setState({
            fileData: base64Content,
        });
    };
    reader.readAsDataURL(file);
};

handleSendClick = async () => {
    const { newMessage, fileName, fileData ,fileTypes } = this.state;
    const { context, entityId , formEntityType, datasetEntityType ,loadFirstPage } = this.props;

    console.log("filesize",fileTypes);
    console.log("filname",fileName);
    console.log("filData",fileData);
    if (newMessage.trim() !== '' || (fileName && fileData && fileTypes)) {
        this.setState({ isLoading: true }); // Start loading
        try {
            // Fetch activity parties
            const query = `?$filter=_activityid_value eq '${entityId}' and (participationtypemask eq 1 or participationtypemask eq 2)`;
            const fetchedRecords = await context.webAPI.retrieveMultipleRecords("activityparty", query);
            // console.log("Fetched Records:", fetchedRecords);

            let fromResult: { guildId: string; entityType: string } = { guildId: '', entityType: '' };
            let toResult: { guildId: string; entityType: string } = { guildId: '', entityType: '' };

            // Function to check guildId against systemuser and account entities
            const checkGuild = async (guildId: string): Promise<{ guildId: string; entityType: string }> => {
                const entities = ['systemuser', 'account'];
                for (const entity of entities) {
                    try {
                        const fetchedRecord = await context.webAPI.retrieveRecord(entity, guildId);
                        if (fetchedRecord) {
                            return { guildId, entityType: entity };
                        }
                    } catch (error) {
                        console.error(`Error fetching from ${entity}: ${error}`);
                    }
                }
                return { guildId, entityType: '' }; // If not found, entityType will be an empty string
            };

            // Process fetchedRecords to determine fromGuild and toGuild
            fetchedRecords.entities.forEach(record => {
                if (record.participationtypemask === 1) {
                    fromResult.guildId = record._partyid_value;
                } else if (record.participationtypemask === 2) {
                    toResult.guildId = record._partyid_value;
                }
            });

            // Check From Guid
            fromResult = await checkGuild(fromResult.guildId);
            // Check To Guid
            toResult = await checkGuild(toResult.guildId);

            // Create new record
            const newRecord = {
                dyn_direction: 2,
                description: '',
                "dyn_sessionid_dyn_wappmessage@odata.bind": `/${formEntityType}s(${entityId})`,
                "dyn_wappmessage_activity_parties": [
                    {
                        [`partyid_${fromResult.entityType}@odata.bind`]: `/${fromResult.entityType}s(${fromResult.guildId})`,
                        participationtypemask: 1
                    },
                    {
                        [`partyid_${toResult.entityType}@odata.bind`]: `/${toResult.entityType}s(${toResult.guildId})`,
                        participationtypemask: 2
                    }
                ]
            };

            if (newMessage.trim() !== '') {
                newRecord.description = newMessage;
            }
            // Create the record and capture the response
            const createdRecord = await context.webAPI.createRecord(`${datasetEntityType}`, newRecord);
            const recordId = createdRecord.id;
            // console.log('Created record ID:', recordId);
            if(fileName && fileName && recordId){//if fileName and fileData is added and recordid is created
            //File Upload using power automate
            const input = JSON.stringify({
                "messageid": recordId,
                "fileName": fileName,
                "fileData": fileData,
                "fileType": fileTypes
            });
            try {
                const uploadUrl = `<Enter Your Power automate URL for upload>`;
        
                // POST the file data
                const response = await fetch(uploadUrl, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: input
                });
        
                if (response.ok) {
                    const jsonResponse = await response.json();
                    const resMessage = jsonResponse.response;
                    console.log(resMessage);

                } else {
                    console.error('Error uploading file:', response.statusText);
                }
            } catch (error) {
                console.error('Error uploading file:', error);
            }
        } 
            // Refresh the dataset to get the latest records
            context.parameters.Whatsapp_session.refresh();
            loadFirstPage();
            // Optionally, you can perform actions after the refresh if needed
            console.log("Record created successfully and dataset refreshed.");
            // Reset newMessage state
            this.setState({ 
                newMessage: '',
                fileName: '',
                fileData: '',
                fileTypes: ''
            });
        } catch (error) {
            console.error('Error in handleSendClick:', error);
        }finally {
            this.setState({ isLoading: false,shouldScrollToBottom: true }); // Stop loading
        }
    }
};

clearFile = () => {
    this.setState({
        selectedFile: null,
        fileName: '',
        fileData: '',
        fileTypes: ''
    });
    // Reset the file input value
    if (this.fileInputRef.current) {
        this.fileInputRef.current.value = '';
    }
};
// Function to download file based on record id and file name
downloadFile = async (recordId: string, filename: string, filetype: string) => {
    const input = JSON.stringify({
        "messageid": recordId
    });
    try {
        // Construct the download URL based on your environment
        const downloadUrl = `<Enter Your Power automate URL for download>`;

        // Fetch the file data
        const response = await fetch(downloadUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: input
        });

        if (response.ok) {
            // Convert response to blob
            const jsonResponse = await response.json();
            // Create URL for the blob
            const fileData = jsonResponse.file;
            const mimeType = filetype;
            // Convert Base64 string to Uint8Array
            const byteCharacters = atob(fileData);
            const byteNumbers = new Array(byteCharacters.length);
            for (let i = 0; i < byteCharacters.length; i++) {
                byteNumbers[i] = byteCharacters.charCodeAt(i);
            }
            const byteArray = new Uint8Array(byteNumbers);

            // Create a Blob from the file content with the determined MIME type
            const blob = new Blob([byteArray], { type: mimeType });
            const blobUrl = URL.createObjectURL(blob);
            // Create a link element
            const link = document.createElement('a');
            link.href = blobUrl;
            link.target = '_blank'; // Open in a new tab
            link.download = filename; // Set the file name for download
            link.click();
        } else {
            console.error('Error downloading file:', response.statusText);
        }
    } catch (error) {
        console.error('Error downloading file:', error);
    }
};

    public render(): React.ReactNode {
        const { records, currentPage, hasNextPage} = this.props;
        const paginatedRecords = [...records].reverse(); // Use spread operator to avoid mutating props
        const { isLoading } = this.state;
        return (
            <div className='chat-container'>
                <PrimaryButton onClick={this.handleNextPage} 
                style={{ display: hasNextPage ? 'inline-block' : 'none' }}
                >Load Previous Chats</PrimaryButton>
            <div className='chat' ref={this.containerRef}>
                {paginatedRecords.length === 0 ? (
                    <div className="no-messages">
                        <p>No messages yet. Start the conversation!</p>
                    </div>
                ) : (
                    paginatedRecords.map((record, index) => (
                        <div key={index} className={record.dyn_direction === 'Inbound' ? 'left' : 'right'}>
                            <Persona
                                className="persona"
                                initialsColor={
                                    record.dyn_direction === 'Outbound' ? '#3498db' :
                                    record.dyn_direction === 'Inbound' ? '#666666' :
                                    '#666666' // Fallback color if neither condition matches
                                }
                                size={PersonaSize.size32}
                                text={record.from ?? 'Unknown'}
                                styles={{
                                    primaryText: {
                                        color: 'black',
                                        fontWeight: 'bold'
                                    }
                                }}
                            />
                            <div className="chat-message">
                            {record.fileName && (
                                <b className='attachment'><RiFileDownloadFill onClick={() => this.downloadFile(record.id,record.fileName,record.fileType)} size={30} style={{ cursor: 'pointer'}}/> {record.fileName?? ''}</b>
                                 )}
                                 {record.fileName && <br/>}
                                <b>{record.description ?? ''}</b>
                            </div>
                            <b className="time">{this.formatDate(record.createdon) ?? ''}</b>
                        </div>
                    ))
                )}
            </div>
            {/* <PrimaryButton onClick={this.handlePreviousPage} style={{ display: currentPage === 1 ? 'none' : 'inline-block' }}>Load Next Chats</PrimaryButton> */}
            {this.state.fileName && (
                        <div className="selected-file-name">
                            {this.state.fileName}
                            <RiCloseLine className="clear-file-icon" onClick={this.clearFile} />
                        </div>
                    )}
            <div className='new-message'>
                <textarea
                    className="chat-textarea"
                    placeholder="Type your message here..."
                    value={this.state.newMessage}
                    onChange={this.handleInputChange}
                />
                <label className="upload-button">
                <RiFileUploadLine size={55} style={{ color: '#0078d4' , transition: 'color 0.3s', cursor: 'pointer'}} />
                <input type="file" onChange={this.handleFileChange} style={{ display: 'none' }} ref={this.fileInputRef}/>
                </label>
                <button onClick={this.handleSendClick} className="chat-button" disabled={isLoading}>{isLoading ? (
                            <Spinner size={SpinnerSize.medium} />
                        ) : (
                            <>Send <RiSendBackward /></>
                        )}
                        </button>
            </div>
        </div>
        );
    }
}

Step 5: Styling the Chat Interface

In Chat.css, add the necessary styles:

:root {
  font-size: 16px;
}

.chat-container {
  width: 99%;
  height: 100%;
  max-height: 37.5rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  position: relative;
  border: 1px solid #ccc; /* Add border around chat-container */
  border-radius: 8px;
  /* padding: 1rem; */
}
.no-messages {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%; /* Ensures it takes full height of the chat container */
  color: #666666; /* Example text color */
  font-size: 1.5rem; /* Example font size */
  font-weight: bold;
  text-align: center;
  /* padding: 2rem;  */
}

.chat {
  flex: 1;
  width: 99%;
  height: 100%;
  padding: 1rem;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  overflow-y: auto;
  /* border: 1px solid #ccc;
  border-radius: 8px; */
  background-color: #fefefe;
}

.left, .right {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  position: relative;
}

.left {
  align-self: flex-start;
  margin-right: auto;
}

.right {
  align-self: flex-end;
  margin-left: auto;
  align-items: flex-end;
}

.persona-container {
  margin-bottom: 0.5rem;
}


.chat-message {
  display: block;
  width: fit-content;
  padding: 1rem;
  max-width: 37.5rem;
  border-radius: 0.25rem;
  box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
  background-color: white;
  position: relative;
  word-wrap: break-word;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

.chat-message b:nth-child(2) {
  display: inline; /* Ensures paragraphs stay within the chat message block */
  margin: 0;
  padding: 1rem;

}

.left .chat-message {
  background-color: #f2f2f2;
}

.right .chat-message {
  background-color: #e6f7ff;
  /* text-align: right; */
}

.left .time {
  font-size: 0.8rem;
  color: #666;
  position: absolute;
  bottom: -1.5rem;
  left: 1rem;
}

.right .time {
  font-size: 0.8rem;
  color: #666;
  position: absolute;
  bottom: -1.5rem;
  right: 1rem;
}

.new-message {
  width: 98%;
  display: flex;
  align-items: center;
  padding: 0.5rem;
  border-radius: 1rem 1rem 0.5rem 0.5rem;
  background-color: #f9f9f9;
  position: sticky;
  bottom: 0;
  z-index: 1;
}

.new-message .chat-textarea {
  flex: 1;
  min-height: 3rem;
  max-height: 6rem;
  resize: vertical;
  margin-right: 0.5rem;
  padding: 0.5rem;
  font-size: 1rem;
  border: 1px solid #ccc;
  border-radius: 0.25rem;
  box-sizing: border-box;
}

.new-message .chat-button {
  height: 3rem;
  padding: 0 1rem;
  font-size: 1rem;
  background-color: #0078d4;
  color: white;
  border: none;
  border-radius: 0.25rem;
  cursor: pointer;
}

.new-message .chat-button:hover {
  background-color: #005a9e;
}

.attachment {
  display: flex;
  align-items: center;
  background-color: #e0e0e0;
  color: #000;
  padding: 0.5rem;
  border-radius: 0.5rem;
  box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1); /* 0 4px 8px converted to rem */
}
.right .attachment{
  background-color: #f0faff;
}
.left .attachment{
  background-color: #fafafa;
}
.selected-file-name {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  margin-right: auto;
  margin-bottom: 0.25rem;
  margin-left: 1rem;
  font-size: 0.9rem;
  color: #333;
  background-color: #f0f0f0;
  padding: 0.5rem;
  border-radius: 0.25rem;
  text-align: center;
  box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
}

.clear-file-icon {
  color: #e81123;
  cursor: pointer;
  margin-left: 0.5rem;
}

@media (max-width: 768px) {
  .no-messages {
    font-size: 1.2rem; /* Adjust font size for smaller screens */
  }
  .new-message {
    flex-direction: column;
    align-items: stretch;
  }

  .new-message .chat-textarea, .new-message .chat-button {
    width: 100%;
    margin: 0.25rem 0;
  }

  .new-message .chat-button {
    height: 2.5rem;
  }
  .attachment {
    padding: 0.25rem;
    font-size: 0.9rem;
  }
  .selected-file-name {
    justify-content: center; 
    margin-left: 0; 
    padding: 0.3rem; 
    font-size: 0.8rem; 
}

.clear-file-icon {
    margin-left: 0.25rem; 
    font-size: 0.9rem; 
}
}

Step 6: Creating a Solution Package

With your PCF control built, it’s essential to package it correctly. Open the terminal, change the directory into the solution folder, and run the following command to create a solution package for your PCF control:

pac solution init --publisher-prefix your_prefix --publisher-name your_name

Replace your_prefix and your_name with the appropriate publisher prefix and publisher name.

Step 7: Adding the PCF Control to the Solution

With your PCF control built, it’s essential to package it correctly. Open the terminal, change the directory into the solution folder, and run the following command to create a solution package for your PCF control:

pac solution add-reference --path ./your_control_folder

Replace your_control_folder with the actual folder path where your PCF control is located.

step 8: Build the solution Zip File

Open the Visual Studio developer command prompt and change the directory into the solution folder. Run the following command to build the solution zip file:

For an unmanaged debug solution:

msbuild /t:build /restore   

For a managed release solution:

msbuild /p:configuration=Release

Demo

Demo of the Chat Interface PCF-Control

GitHub Repository

Explore our GitHub repository here for a hands-on experience with the Chat Interface PCF control.

Conclusion

By following these steps, you can create a dynamic and interactive chat interface using PCF, React, and Fluent UI. This setup provides a robust foundation for further customization and enhancements, such as adding file uploads, read receipts, and more complex message handling.

Feel free to experiment and expand on this basic setup to fit your specific requirements. Happy coding!