Skip to content

Commit

Permalink
Merge pull request #46 from fleetbase/feature/chat
Browse files Browse the repository at this point in the history
Feature: Chat
  • Loading branch information
roncodes authored Apr 13, 2024
2 parents 9de0535 + 42b4d08 commit aa08737
Show file tree
Hide file tree
Showing 72 changed files with 2,213 additions and 156 deletions.
16 changes: 9 additions & 7 deletions addon/components/badge.hbs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
<div class="status-badge {{safe-dasherize (or @type @status)}}-status-badge" ...attributes>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium leading-4 whitespace-no-wrap {{@spanClass}}">
<svg class="mr-1.5 h-2 w-2 {{if @hideStatusDot "hidden"}}" fill="currentColor" viewBox="0 0 8 8">
<span class="inline-flex items-center {{unless @roundedFull "px-2 py-0.5 rounded" "badge-rounded-full"}} text-xs font-medium leading-4 whitespace-no-wrap {{@spanClass}}">
<svg class="{{unless @hideText "mr-1.5"}} h-2 w-2 {{if @hideStatusDot "hidden"}}" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3"></circle>
</svg>
{{#if (has-block)}}
{{yield @status}}
{{else}}
{{#if @disableHumanize}}
{{@status}}
{{else}}
{{safe-humanize @status}}
{{/if}}
{{#unless @hideText}}
{{#if @disableHumanize}}
{{@status}}
{{else}}
{{safe-humanize @status}}
{{/if}}
{{/unless}}
{{/if}}
{{#if @helpText}}
<Attach::Tooltip @class="clean" @animation="scale" @placement={{or @tooltipPlacement "top" }}>
Expand Down
5 changes: 5 additions & 0 deletions addon/components/chat-container.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="chat-container" ...attributes>
{{#each this.chat.openChannels as |chatChannel|}}
<ChatWindow @channel={{chatChannel}} />
{{/each}}
</div>
10 changes: 10 additions & 0 deletions addon/components/chat-container.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class ChatContainerComponent extends Component {
@service chat;
constructor() {
super(...arguments);
this.chat.restoreOpenedChats();
}
}
55 changes: 55 additions & 0 deletions addon/components/chat-tray.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<div class="next-user-button" ...attributes>
<BasicDropdown @registerAPI={{this.registerAPI}} @defaultClass={{@wrapperClass}} @onOpen={{this.unlockAudio}} @onClose={{@onClose}} @verticalPosition={{@verticalPosition}} @horizontalPosition={{@horizontalPosition}} @renderInPlace={{true}} @initiallyOpened={{@initiallyOpened}} as |dd|>
<dd.Trigger class={{@triggerClass}}>
<div class="next-org-button-trigger chat-tray-icon flex-shrink-0 {{if dd.isOpen 'is-open'}}">
<FaIcon @icon="message" />
{{#if this.unreadCount}}
<div class="chat-tray-unread-notifications-badge">{{this.unreadCount}}</div>
{{/if}}
</div>
</dd.Trigger>
<dd.Content class="chat-tray-panel-container">
<div class="chat-tray-panel">
<div class="p-4">
<Button @type="primary" @text="Start Chat" @icon="paper-plane" @onClick={{dropdown-fn dd this.startChat}} />
</div>
<div class="flex flex-col">
{{#each this.channels as |channel|}}
<div class="chat-tray-channel-preview flex items-start px-4 py-3 border-t dark:border-gray-700 border-gray-200">
<button type="button" class="chat-tray-channel-preview-btn flex flex-col flex-1 cursor-default" {{on "click" (dropdown-fn dd this.openChannel channel)}}>
<div class="flex items-center mb-2">
<div class="chat-tray-channel-preview-title flex self-start items-center font-bold">{{n-a channel.title "Untitled Chat"}}</div>
{{#if channel.unread_count}}
<Badge @status="info" @hideStatusDot={{true}} class="flex self-start ml-2">{{pluralize channel.unread_count "Unread"}}</Badge>
{{/if}}
</div>
<div class="flex flex-row">
<div class="w-10">
<Image src={{channel.last_message.sender.avatar_url}} @fallbackSrc={{config "defaultValues.userImage"}} alt={{channel.last_message.sender.name}} class="chat-tray-channel-preview-avatar rounded-full shadow-sm w-8 h-8" />
</div>
<div class="chat-tray-channel-preview-last-message text-sm truncate dark:text-gray-200 text-gray-900">
<span>{{channel.last_message.content}}</span>
{{#if channel.last_message.attachments}}
<div class="chat-tray-channel-preview-last-message-attachments">
<FaIcon @icon="paperclip" @size="sm" class="mr-0.5" /> {{pluralize channel.last_message.attachments.length "Attachment"}}
</div>
{{/if}}
</div>
</div>
</button>
<div class="flex">
{{#if (eq channel.created_by_uuid this.currentUser.id)}}
<div class="btn-wrapper">
<button type="button" class="chat-tray-channel-preview-close-channel-btn btn btn-danger btn-xs cursor-default" {{on "click" (dropdown-fn dd this.removeChannel channel)}}>
<FaIcon @icon="times" @size="sm" />
</button>
</div>
{{/if}}
</div>
</div>
{{/each}}
</div>
</div>
</dd.Content>
</BasicDropdown>
</div>
205 changes: 205 additions & 0 deletions addon/components/chat-tray.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { isNone } from '@ember/utils';
import { task } from 'ember-concurrency';
import noop from '../utils/noop';

export default class ChatTrayComponent extends Component {
@service chat;
@service socket;
@service fetch;
@service store;
@service modalsManager;
@service currentUser;
@tracked channels = [];
@tracked unreadCount = 0;
@tracked notificationSound = new Audio('/sounds/message-notification-sound.mp3');

constructor() {
super(...arguments);
this.chat.loadChannels.perform({
withChannels: (channels) => {
this.channels = channels;
this.countUnread(channels);
this.listenAllChatChannels(channels);
this.listenUserChannel();
},
});
}

willDestroy() {
this.chat.off('chat.feed_updated', this.reloadChannelWithDelay.bind(this));
super.willDestroy(...arguments);
}

listenAllChatChannels(channels) {
channels.forEach((chatChannelRecord) => {
this.listenChatChannel(chatChannelRecord);
});
}

async listenUserChannel() {
this.socket.listen(`user.${this.currentUser.id}`, (socketEvent) => {
const { event, data } = socketEvent;
switch (event) {
case 'chat.participant_added':
case 'chat_participant.created':
this.reloadChannels();
break;
case 'chat.participant_removed':
case 'chat_participant.deleted':
this.reloadChannels();
this.closeChannelIfRemovedFromParticipants(data);
break;
case 'chat_channel.created':
this.reloadChannels({ relisten: true });
this.openNewChannelAsParticipant(data);
break;
case 'chat_channel.deleted':
this.reloadChannels({ relisten: true });
this.closeChannelIfOpen(data);
break;
}
});
}

async listenChatChannel(chatChannelRecord) {
this.socket.listen(`chat.${chatChannelRecord.public_id}`, (socketEvent) => {
const { event, data } = socketEvent;
switch (event) {
case 'chat_message.created':
this.reloadChannels();
this.playSoundForIncomingMessage(chatChannelRecord, data);
break;
case 'chat.added_participant':
this.reloadChannels();
break;
case 'chat_participant.deleted':
case 'chat.removed_participant':
this.reloadChannels();
this.closeChannelIfRemovedFromParticipants(data);
break;
case 'chat_channel.created':
this.reloadChannels({ relisten: true });
this.openNewChannelAsParticipant(data);
break;
case 'chat_channel.deleted':
this.reloadChannels({ relisten: true });
this.closeChannelIfOpen(data);
break;
case 'chat_receipt.created':
this.reloadChannels({ relisten: true });
break;
}
});
}

@action openChannel(chatChannelRecord) {
this.chat.openChannel(chatChannelRecord);
this.reloadChannels({ relisten: true });
}

@action startChat() {
this.chat.createChatChannel('Untitled Chat').then((chatChannelRecord) => {
this.openChannel(chatChannelRecord);
});
}

@action removeChannel(chatChannelRecord) {
this.modalsManager.confirm({
title: `Are you sure you wish to end this chat (${chatChannelRecord.title})?`,
body: 'Once this chat is ended, it will no longer be accessible for anyone.',
confirm: (modal) => {
modal.startLoading();

this.chat.closeChannel(chatChannelRecord);
this.chat.deleteChatChannel(chatChannelRecord);
return this.reloadChannels();
},
});
}

@action updateChatChannel(chatChannelRecord) {
this.chat.deleteChatChannel(chatChannelRecord);
this.reloadChannels();
}

@action async unlockAudio() {
this.reloadChannels();
try {
this.notificationSound.play().catch(noop);
this.notificationSound.pause();
this.notificationSound.currentTime = 0;
} catch (error) {
noop();
}
}

@task *getUnreadCount() {
const { unreadCount } = yield this.fetch.get('chat-channels/unread-count');
if (!isNone(unreadCount)) {
this.unreadCount = unreadCount;
}
}

playSoundForIncomingMessage(chatChannelRecord, data) {
const sender = this.getSenderFromParticipants(chatChannelRecord);
const isNotSender = sender ? sender.id !== data.sender_uuid : false;
if (isNotSender) {
this.notificationSound.play();
}
}

getSenderFromParticipants(channel) {
const participants = channel.participants ?? [];
return participants.find((chatParticipant) => {
return chatParticipant.user_uuid === this.currentUser.id;
});
}

countUnread(channels) {
this.unreadCount = channels.reduce((total, channel) => total + channel.unread_count, 0);
}

reloadChannels(options = {}) {
return this.chat.loadChannels.perform({
withChannels: (channels) => {
this.channels = channels;
this.countUnread(channels);
if (options && options.relisten === true) {
this.listenAllChatChannels(channels);
}
},
});
}

openNewChannelAsParticipant(data) {
const normalized = this.store.normalize('chat-channel', data);
const channel = this.store.push(normalized);
if (channel && this.getSenderFromParticipants(channel)) {
this.notificationSound.play();
this.openChannel(channel);
}
}

closeChannelIfOpen(data) {
const normalized = this.store.normalize('chat-channel', data);
const channel = this.store.push(normalized);
if (channel) {
this.chat.closeChannel(channel);
}
}

closeChannelIfRemovedFromParticipants(data) {
const normalized = this.store.normalize('chat-participant', data);
const removedChatParticipant = this.store.push(normalized);
if (removedChatParticipant) {
const channel = this.store.peekRecord('chat-channel', removedChatParticipant.chat_channel_uuid);
if (channel) {
this.chat.closeChannel(channel);
}
}
}
}
84 changes: 84 additions & 0 deletions addon/components/chat-window.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{{#if this.isVisible}}
<div id={{concat "channel-" this.channel.id "-window"}} class="chat-window-container {{if this.pendingAttachmentFiles "has-attachments"}}" {{did-insert this.positionWindow}} ...attributes>
<div class="chat-window-controls-container">
<div class="chat-window-name">
{{n-a this.channel.name "Untitled Chat"}}
<a href="#" {{on "click" this.editChatName}} class="ml-2 hover:opacity-50">
<FaIcon @icon="pencil" @size="xs" />
</a>
</div>
<div id={{concat "channel-" this.channel.id "-controls"}} class="chat-window-controls">
<DropdownButton class="chat-window-button" @icon="user-plus" @size="sm" @iconPrefix="fas" @triggerClass="hidden md:flex" @disabled={{not this.availableUsers}} as |dd|>
<div class="next-dd-menu mt-1 mx-0" aria-labelledby="user-menu">
<div class="p-1">
{{#each this.availableUsers as |user|}}
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.addParticipant user)}}>
<div class="flex-1 flex flex-row items-center">
<Badge @status={{if user.is_online "online" "offline"}} @hideText={{true}} @roundedFull={{true}} class="mr-2" />
<div class="w-6">
<FaIcon @icon="user" @size="xs" />
</div>
<span>{{user.name}}</span>
</div>
</a>
{{/each}}
</div>
</div>
</DropdownButton>
<button type="button" class="chat-window-button chat-window-close-button" {{on "click" this.closeChannel}}>
<FaIcon @icon="times" @size="sm" />
</button>
</div>
</div>
<div id={{concat "channel-" this.channel.id "-participants"}} class="chat-window-participants-container">
{{#each this.channel.participants as |chatParticipant|}}
<div class="chat-window-participant-bubble-container">
<Image src={{chatParticipant.avatar_url}} @fallbackSrc={{config "defaultValues.userImage"}} alt={{chatParticipant.name}} class="chat-window-participant-bubble" />
<Attach::Tooltip @class="clean" @animation="scale" @placement="top">
<InputInfo @text={{chatParticipant.name}} />
</Attach::Tooltip>
{{#if (can-remove-chat-participant this.channel this.sender chatParticipant)}}
<button type="button" class="chat-window-remove-participant" {{on "click" (fn this.removeParticipant chatParticipant)}}>
<FaIcon @icon="times" @size="sm" />
</button>
{{/if}}
<div class="chat-window-participant-online-status {{if chatParticipant.is_online "is-online"}}" />
</div>
{{/each}}
</div>
<div id={{concat "channel-" this.channel.id "-feed"}} class="chat-window-messages-container" {{did-insert this.scrollMessageWindowBottom}}>
<ChatWindow::Feed @channel={{this.channel}} @chatParticipant={{this.sender}} />
</div>
<div id={{concat "channel-" this.channel.id "-input"}} class="chat-window-input-container {{if this.pendingAttachmentFiles "has-attachments"}}">
<div class="chat-window-attachments-container">
<div class="chat-window-attachment-input">
<FileUpload @name={{this.customField.name}} @for={{this.customField.name}} @accept={{join "," this.acceptedFileTypes}} @multiple={{true}} @onFileAdded={{perform this.uploadAttachmentFile}}>
<a tabindex={{0}} class="btn btn-default btn-xs cursor-pointer">
<FaIcon @icon="paperclip" @size="sm" class="mr-2" />
<span>Add Attachment</span>
</a>
</FileUpload>
{{#if this.pendingAttachmentFile}}
<div class="ml-2 flex items-center text-sm">
<Spinner class="dark:text-blue-400 text-blue-900" />
<span class="ml-2 text-xs dark:text-blue-400 text-blue-900">{{round this.pendingAttachmentFile.progress}}%</span>
</div>
{{/if}}
</div>
{{#if this.pendingAttachmentFiles}}
<div class="chat-window-pending-attachments-container">
{{#each this.pendingAttachmentFiles as |pendingFile|}}
<ChatWindow::PendingAttachment @file={{pendingFile}} @onRemove={{fn this.removePendingAttachmentFile pendingFile}} />
{{/each}}
</div>
{{/if}}
</div>
<div class="chat-window-input-box">
<Textarea @value={{this.pendingMessageContent}} {{on "keypress" this.handleKeyPress}} placeholder="Type your message here" class="chat-window-input" rows="3" />
</div>
<div class="chat-window-submit-container">
<Button @type="primary" @icon="paper-plane" @text="Send" @size="xs" @onClick={{perform this.sendMessage}} @disabled={{not this.pendingMessageContent}} @isLoading={{and (not this.sendMessage.isIdle) (not this.uploadAttachmentFile.isIdle)}} />
</div>
</div>
</div>
{{/if}}
Loading

0 comments on commit aa08737

Please sign in to comment.