Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion addon/components/button.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
>
{{#if @isLoading}}
<span class="btn-icon-wrapper btn-loading-icon-wrapper" data-icon-prefix={{@iconPrefix}}>
<Spinner @width={{or @loaderWidth 14}} @height={{or @loaderHeight 14}} />
<Spinner @width={{or @loaderWidth 14}} @height={{or @loaderHeight 14}} @wrapperClass="{{unless @text 'mr-0i'}}" />
</span>
{{/if}}
{{#if this.showIcon}}
Expand Down
143 changes: 69 additions & 74 deletions addon/components/chat-tray.hbs
Original file line number Diff line number Diff line change
@@ -1,75 +1,70 @@
<div class="next-user-button" ...attributes>
<BasicDropdown
@registerAPI={{this.registerAPI}}
@defaultClass={{@wrapperClass}}
@onOpen={{this.unlockAudio}}
@onClose={{@onClose}}
@calculatePosition={{this.calculatePosition}}
@verticalPosition={{@verticalPosition}}
@horizontalPosition={{@horizontalPosition}}
@renderInPlace={{or @renderInPlace (not (media "isMobile"))}}
as |dd|
<div class="next-user-button chat-tray" ...attributes>
<button
type="button"
class="chat-tray-panel-trigger {{@triggerClass}} {{if (media 'isMobile') 'is-mobile'}}"
aria-label="Open chat inbox"
aria-expanded={{if this.isInboxOpen "true" "false"}}
{{on "click" this.toggleInbox}}
>
<dd.Trigger class="chat-tray-panel-trigger {{@triggerClass}} {{if (media 'isMobile') 'is-mobile'}}">
<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 {{@contentClass}} {{if (media 'isMobile') 'is-mobile'}}">
<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>
<span class="next-org-button-trigger chat-tray-icon flex-shrink-0 {{if this.isInboxOpen 'is-open'}}">
<FaIcon @icon="message" />
{{#if this.unreadCount}}
<span class="chat-tray-unread-notifications-badge">{{this.unreadCount}}</span>
{{/if}}
</span>
</button>

{{#if this.isInboxOpen}}
<Overlay
@isOpen={{this.isInboxOpen}}
@position="right"
@width={{this.inboxWidth}}
@fullHeight={{true}}
@noBackdrop={{true}}
@overlayClass="chat-inbox-overlay"
@containerClass="chat-inbox-overlay-container"
@onClose={{this.closeInbox}}
>
<ChatTray::InboxPanel
@channels={{this.filteredChannels}}
@searchQuery={{this.searchQuery}}
@unreadCount={{this.unreadCount}}
@isLoading={{not this.chat.loadChannels.isIdle}}
@setSearchQuery={{this.setSearchQuery}}
@openChannel={{this.openChannel}}
@removeChannel={{this.removeChannel}}
@startChat={{this.startChat}}
@close={{this.closeInbox}}
/>
</Overlay>
{{/if}}

{{#if this.isComposeOpen}}
<Overlay
@isOpen={{this.isComposeOpen}}
@position="right"
@width={{this.composeWidth}}
@fullHeight={{true}}
@noBackdrop={{true}}
@overlayClass="chat-compose-overlay {{if this.isInboxOpen 'has-inbox'}}"
@containerClass="chat-compose-overlay-container"
@onClose={{this.closeCompose}}
>
<ChatTray::ComposePanel
@users={{this.availableUsers}}
@selectedUsers={{this.selectedUsers}}
@searchQuery={{this.contactSearchQuery}}
@channelName={{this.newChatName}}
@isLoading={{not this.loadAvailableUsers.isIdle}}
@isSaving={{not this.createChat.isIdle}}
@canCreate={{this.canCreateChat}}
@setSearchQuery={{this.setContactSearchQuery}}
@setChannelName={{this.setNewChatName}}
@toggleUser={{this.toggleSelectedUser}}
@removeSelectedUser={{this.removeSelectedUser}}
@create={{perform this.createChat}}
@close={{this.closeCompose}}
/>
</Overlay>
{{/if}}
</div>
176 changes: 152 additions & 24 deletions addon/components/chat-tray.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ 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 { isBlank, isNone } from '@ember/utils';
import { task } from 'ember-concurrency';
import calculatePosition from 'ember-basic-dropdown/utils/calculate-position';
import noop from '../utils/noop';

export default class ChatTrayComponent extends Component {
Expand All @@ -15,8 +14,16 @@ export default class ChatTrayComponent extends Component {
@service modalsManager;
@service currentUser;
@service media;
@service notifications;
@tracked channels = [];
@tracked unreadCount = 0;
@tracked isInboxOpen = false;
@tracked isComposeOpen = false;
@tracked searchQuery = '';
@tracked contactSearchQuery = '';
@tracked availableUsers = [];
@tracked selectedUsers = [];
@tracked newChatName = '';
@tracked notificationSound = new Audio('/sounds/message-notification-sound.mp3');

constructor() {
Expand All @@ -31,29 +38,52 @@ export default class ChatTrayComponent extends Component {
});
}

/**
* Calculate dropdown content position.
*
* @param {HTMLElement} trigger
* @param {HTMLElement} content
* @return {Object}
* @memberof ChatTrayComponent
*/
@action calculatePosition(trigger, content) {
if (this.media.isMobile) {
content.classList.add('is-mobile');
const triggerRect = trigger.getBoundingClientRect();
const top = triggerRect.height + triggerRect.top;

return { style: { left: '0px', right: '0px', top, width: '100%' } };
get inboxWidth() {
return this.media.isMobile ? '100%' : '420px';
}

get composeWidth() {
return this.media.isMobile ? '100%' : '360px';
}

get filteredChannels() {
const query = this.searchQuery.trim().toLowerCase();
const channels = [...(this.channels ?? [])].sort((a, b) => {
const aUnread = a.unread_count ?? 0;
const bUnread = b.unread_count ?? 0;

if (aUnread !== bUnread) {
return bUnread - aUnread;
}

return new Date(b.updated_at ?? b.created_at ?? 0) - new Date(a.updated_at ?? a.created_at ?? 0);
});

if (isBlank(query)) {
return channels;
}

return calculatePosition(...arguments);
return channels.filter((channel) => this.channelMatchesQuery(channel, query));
}

get selectedUserIds() {
return this.selectedUsers.map((user) => user.id);
}

willDestroy() {
this.chat.off('chat.feed_updated', this.reloadChannelWithDelay.bind(this));
super.willDestroy(...arguments);
get canCreateChat() {
return this.selectedUsers.length > 0 && this.createChat.isIdle;
}

get defaultNewChatName() {
if (this.selectedUsers.length === 1) {
return this.selectedUsers[0].name;
}

if (this.selectedUsers.length > 1) {
return this.selectedUsers.map((user) => user.name).join(', ');
}

return 'Untitled Chat';
}

listenAllChatChannels(channels) {
Expand Down Expand Up @@ -120,13 +150,65 @@ export default class ChatTrayComponent extends Component {

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

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

@action toggleInbox() {
if (this.isInboxOpen) {
this.closeInbox();
} else {
this.openInbox();
}
}

@action openInbox() {
this.isInboxOpen = true;
this.unlockAudio();
}

@action closeInbox() {
this.isInboxOpen = false;
this.isComposeOpen = false;
}

@action closeCompose() {
this.isComposeOpen = false;
this.selectedUsers = [];
this.newChatName = '';
this.contactSearchQuery = '';
}

@action setSearchQuery(event) {
this.searchQuery = event.target.value;
}

@action setContactSearchQuery(event) {
this.contactSearchQuery = event.target.value;
this.loadAvailableUsers.perform({ query: this.contactSearchQuery });
}

@action setNewChatName(event) {
this.newChatName = event.target.value;
}

@action toggleSelectedUser(user) {
const exists = this.selectedUsers.find((selectedUser) => selectedUser.id === user.id);

if (exists) {
this.selectedUsers = this.selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
} else {
this.selectedUsers = [...this.selectedUsers, user];
}
}

@action removeSelectedUser(user) {
this.selectedUsers = this.selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
}

@action removeChannel(chatChannelRecord) {
Expand Down Expand Up @@ -166,6 +248,36 @@ export default class ChatTrayComponent extends Component {
}
}

@task *loadAvailableUsers(params = {}) {
try {
const users = yield this.fetch.get('chat-channels/available-participants', params, {
normalizeToEmberData: true,
normalizeModelType: 'user',
});

this.availableUsers = users;
return users;
} catch (error) {
console.warn('Error loading chat participants:', error);
this.availableUsers = [];
}
}

@task *createChat() {
if (this.selectedUsers.length === 0) {
return;
}

try {
const chatChannelRecord = yield this.chat.createChatChannel(this.newChatName.trim() || this.defaultNewChatName, this.selectedUserIds);
this.closeCompose();
this.openChannel(chatChannelRecord);
this.reloadChannels({ relisten: true });
} catch (error) {
this.notifications.error(error.message ?? 'Unable to create chat.');
}
}

playSoundForIncomingMessage(chatChannelRecord, data) {
const sender = this.getSenderFromParticipants(chatChannelRecord);
const isNotSender = sender ? sender.id !== data.sender_uuid : false;
Expand All @@ -185,6 +297,22 @@ export default class ChatTrayComponent extends Component {
this.unreadCount = channels.reduce((total, channel) => total + channel.unread_count, 0);
}

channelMatchesQuery(channel, query) {
const lastMessage = channel.last_message;
const searchable = [
channel.title,
channel.name,
lastMessage?.content,
...(channel.participants ?? []).map((participant) => participant.name),
...(channel.participants ?? []).map((participant) => participant.email),
]
.filter(Boolean)
.join(' ')
.toLowerCase();

return searchable.includes(query);
}

reloadChannels(options = {}) {
return this.chat.loadChannels.perform({
withChannels: (channels) => {
Expand Down
Loading
Loading