Embeddable Chat Module

Objective


The concept of designing a chat module may seem straightforward in theory, but it requires a detailed approach, broken down into several components. The key aspects include:


  1. Developing the Core Chat Module: This involves creating a module that supports real-time communication. It should be versatile enough to accommodate both one-on-one and group discussions.
  2. Flexible Embedding Options: The design needs to allow users the flexibility to embed the chat in two ways: either as a full chat module or as a discreet chat bubble located at the bottom-right corner of the screen, which, when clicked, opens a modal window containing the chat.
  3. Creating an Embeddable Script: The final piece involves designing a script that, when embedded into a website, seamlessly displays the chat module as described above. This script should be easy to integrate, ensuring a smooth user experience.




Implementation


Real-time communication


To enable real-time communication, the chat module needs to implement a publish/subscribe (pub/sub) method. For our purposes, we'll utilize a WebSocket server, establishing a new subscription channel for each chat channel to ensure seamless, live interactions.


enableWebsockets(){
let channel_id = this.getChannelId(),


if (!this.getWebsockets() && channel_id){
let options = {
channel: `channel-${channel_id}`,
onMessage: this.onWebsocketsMessage.bind(this)
};


let __websockets = new APP.Websockets(options);

//setting websockets
this.setWebsockets(__websockets);
}

return this;
}

onWebsocketsMessage(__websockets, message){
if (message && 'type' in message && 'body' in message){
let user = this.getUser();

if (user.id!==message.body.sender_id){
if (message.type==='typing')
this.onMemberTypingStarted(message.body.sender_id,message.body.sender_name);
else if (message.type==='new-message'){
this.onMemberTypingEnded(message.body.message.user_id);
this.onMessageReceived(message.body.message, true);
this.playSound();
}
else if (message.type==='joined-channel')
this.onMemberJoined(message.body.sender_name);
else if (message.type==='left-channel')
this.onMemberLeft(message.body.sender_name);
}
}
}




One-on-one or group discussions


To enhance the user-friendliness of the chat, certain essential features need to be incorporated:

  1. Notifications for Joining/Leaving: Implement alerts to inform all participants whenever someone joins or leaves the chat channel.
  2. Typing Indicators: Introduce a "typing..." notification that appears when any participant starts composing a message.
  3. Persistent Messaging: Ensure all messages are persistent by storing them in a database, allowing for ongoing conversation tracking and history review.


onMemberTypingStarted(senderid,senderme=''){
this.showNotification(<span id="typing-${senderid}"><strong>${sendername}</strong> is typing</span>,10000);
}


onMemberJoined(sender_name){
this.showNotification(`<strong>${sender_name}</strong> joined the channel`,3000);
}

onMemberLeft(sender_name) {
this.channelNotify(`<strong>${sender_name}</strong> left the channel`,3000);
}

notifyChannel(notification={}){
if ('type' in notification && 'body' in notification){
let __websockets = this.getWebsockets(),
user = this.getUser();

if (__websockets){
//lets add the sender id & name
notification.body.sender_id = user.id;
notification.body.sender_name = user.name;

__websockets.send(notification);
}
}
}

async sendMessage(msg=''){
let ajax_settings = this.getAjaxData(),
channel_id = this.getChannelId(),
message = _.trim(msg);

let url = ajax_settings.url,
options = {
method: 'POST',
data: {
method: 'sendMessage',
channel_id,
message: message.urlEncode()
}
};

let response = await $.fetch(url,options);

if (!UTILS.Errors.isError(response)){ //success
this.notifyChannel({
type: 'new-message',
body: {
message: response.message
}
});

this.onMessageReceived(response.message);
}
}

async onMessageReceived(message, is_scroll=false){
let messages = this.getDBData().messages;

//lets add the message to dbdata
messages.push(message);
this.setDBData({ messages });

$('.chat-messages').append(this.renderChatMessage(message));
}




Embeddable script


In designing the embeddable script for our chat module, a key consideration is Cross-Origin Resource Sharing (CORS), which is critical for ensuring smooth integration and functionality on external websites. Here's an expanded view of our implementation strategy:


  1. Script Execution on External Sites: When our script is embedded into an external website, it needs to execute seamlessly in that site's local environment. This is crucial for maintaining a consistent user experience regardless of the hosting website.
  2. Iframe and Bubble Button Integration: The script is designed to perform one of two actions upon execution:
  3. Open an Iframe Directly: This would directly display the chat interface within the page.
  4. Add a Bubble Button: Alternatively, a more discreet bubble button can be added to the website’s interface. When clicked, this button would open the chat interface in an iframe, housed within a modal window. This approach offers a non-intrusive way for users to access the chat.
  5. Handling CORS for Iframe Content: To ensure the iframe content loads without any cross-origin issues, we need to properly set up CORS headers. By setting these headers to '*', we allow resources to be accessed from any origin. This is a critical step, as it enables the chat module's content to be loaded and displayed correctly, regardless of the external site's domain.
  6. Security Considerations: While setting CORS headers to '*' facilitates ease of integration, it's also important to consider potential security implications. We'll need to implement additional safeguards to protect against malicious use or data breaches, ensuring that our chat module remains secure even with open CORS settings.
  7. Performance Optimization: To ensure optimal performance, especially in the context of varying external site environments, the script is optimized for speed and low resource usage. This means ensuring that the iframe or bubble button loads quickly and does not significantly affect the host site's performance.
  8. User Experience Focus: Throughout the development process, maintaining a high-quality user experience is paramount. This includes ensuring that the chat interface is responsive, visually appealing, and user-friendly, regardless of how it is accessed on the host site.


By considering these factors in our implementation, we aim to provide a versatile, secure, and user-friendly chat module that can be easily embedded across various websites, enhancing communication capabilities for users. PS: We wrote it in vanilla JS to make sure it is super performant and quick to load.


(function(w,d,data){
let cache_version = data?.timestamp || 1,
public_url = null,
hostname = null,
$bubble,
$iframe,
$embed_wrapper,
$chat_wrapper, $chat_wrapper_header, $chat_wrapper_content, $chat_wrapper_close_control; //holds the iframe when type=='bubble'

let _createEl = o => {
let type = o.type || 'div',
$el = document.createElement(type);

for (const key of (Object.keys(o))) {
if (key != 'attrs' && key != 'type')
$el[key] = o[key];
}

if (o.attrs) {
for (let key of (Object.keys(o.attrs))) {
let value = o.attrs[key];

if (key != key.toLowerCase())
key = key.replace(/[A-Z]/g, m => "-" + m.toLowerCase());

$el.setAttribute(key, value);
}
}

return $el;
};

let _fetchCSS = url => {
return new Promise((resolve, reject) => {
let link = d.createElement('link');
link.type = 'text/css';
link.rel = 'stylesheet';
link.onload = () => { resolve(); console.log('cogency-embed css has loaded!'); };
link.href = `${url}?v=${cache_version}`;

let head_script = d.querySelector('script');
head_script.parentNode.insertBefore(link, head_script);
});
};

let _fetchJS = url => {
return new Promise((resolve, reject) => {
let script = d.createElement('script');
script.src = `${url}?v=${cache_version}`;
script.onload = resolve;
script.onerror = e => reject(Error(`${url} failed to load`));
d.head.appendChild(script);
});
};

let _fetchScripts = url => {
if (/\.css$/.test(url))
return _fetchCSS(url);
else
return _fetchJS(url);
};

let showChat = event => {
event.preventDefault();

if (!public_url)
return;

createBubbleChatWrapper();

let is_shown = $embed_wrapper.getAttribute('data-is-shown')=='true';

$embed_wrapper.setAttribute('data-is-shown', !is_shown);
};

let createBubbleChatWrapper = () => {
if ($chat_wrapper)
return;

//lets create the chat wrapper
$chat_wrapper = _createEl({
type: 'div',
className: `app-cogency-chat-bubble-embed-wrapper`,
attrs: {}
});
$embed_wrapper.appendChild($chat_wrapper);

$chat_wrapper_header = _createEl({
type: 'div',
className: `app-cogency-chat-bubble-embed-wrapper-header`,
attrs: {}
});
$chat_wrapper_content = _createEl({
type: 'div',
className: `app-cogency-chat-bubble-embed-wrapper-content`,
attrs: {}
});
$chat_wrapper.appendChild($chat_wrapper_header);
$chat_wrapper.appendChild($chat_wrapper_content);

//lets load the iframe
$iframe = createIframe(['is_bubble=true']);
$chat_wrapper_content.appendChild($iframe);

$chat_wrapper_close_control = _createEl({
type: 'a',
className: `app-cogency-chat-bubble-embed-wrapper-close-control`,
attrs: {
'href': '#'
}
});
$chat_wrapper_header.appendChild($chat_wrapper_close_control);

$chat_wrapper_close_control.addEventListener('click', showChat);
};

let getType = () => {
return data?.type;
};

let createBubble = () => {
let $bubble = _createEl({
type: 'div',
className: 'app-cogency-chat-embed-bubble',
attrs: {}
}),
$icon = _createEl({
type: 'div',
className: `bubble-icon`,
attrs: {}
});

$bubble.appendChild($icon);

$bubble.addEventListener('click', showChat);

return $bubble;
};

let createIframe = () => {
let $iframe = _createEl({
type: 'iframe',
className: 'app-cogency-chat-embed-iframe',
innerHTML: data?.options?.text,
attrs: {
src: public_url,
frameborder: 0,
allowfullscreen: 'allowfullscreen',
scrolling: 'no'
}
});

$iframe.addEventListener('load', onIframeLoaded);

return $iframe;
};

let iframeAutoResize = height => {
if (height){
let h = ~~height + 30;
$iframe.style.height = `${h}px`;
$iframe.style.minHeight = `${h}px`;
}
};

let isBubble = () => {
return getType()==='bubble';
};

let isIframe = () => {
return getType()==='iframe';
};

onIframeLoaded = event => {
let is_bubble = isBubble();

if (is_bubble)
iframeAutoResize(670);
};

let loadBubble = () => {
$bubble = createBubble();
document.querySelector('body').appendChild($embed_wrapper);
$embed_wrapper.appendChild($bubble);
};

let loadStandaloneIframe = ($target=$embed_wrapper, callback) => {
$iframe = createIframe();

//adding post message event listener
window.addEventListener('message', event => {
if (!event.origin.match(hostname))
return;

iframeAutoResize(event.data?.height);
});

$target.appendChild($iframe);
};

let _onChannelLinkLoaded = () => {
let parent_id = data.parent_id;

$embed_wrapper = d.querySelector(`#${parent_id}`);

if (!$embed_wrapper)
return;

if (isBubble())
loadBubble();
else
loadStandaloneIframe();
};

let scripts = [
'https://cogency.io/shared/js/vendors/axios.min.js',
'https://cogency.io/shared/css/app/app.embed-chat.min.css'
];

Promise.all(scripts.map(_fetchScripts))
.then(async () => {
console.log('embed resources loaded!');
public_url = await axios.get(`https://cogency.io/embed/chat/${data.id}`).then(response => response.data);

try {
hostname = new URL(public_url).hostname;
} catch(e){}

_onChannelLinkLoaded();
})
.catch(err => console.error(err));
})(window, document, cogency_embed_data||{});


...and of course there is a little bit of CSS that needed to be added in app.embed-chat.min.css.


To optimize performance in our design, we've implemented specific strategies. For example, to prevent the iframe from reloading multiple times or with each click on the bubble, we use a data attribute, data-is-shown="true|false", on the parent wrapper. This approach ensures that the iframe loads just once, allowing for quick display on subsequent clicks, thereby enhancing efficiency and user experience.


Please click the bubble to your right to try it in action 😃 🆒


--Cogency Team