From c599ea966e276d16f24bf08be8d0ac204bbb60eb Mon Sep 17 00:00:00 2001 From: Ondrej Prazak Date: Tue, 29 Dec 2020 17:21:38 +0100 Subject: [PATCH] Fixes #164 - Add typing indicator Adds an indicator when other users in conversation are typing. --- .../conversations/AllConversations.tsx | 4 +++ .../conversations/ClosedConversations.tsx | 4 +++ .../conversations/ConversationFooter.tsx | 22 ++++++++++++ .../conversations/ConversationsContainer.tsx | 9 ++++- .../conversations/ConversationsProvider.tsx | 35 +++++++++++++++++++ .../conversations/MyConversations.tsx | 4 +++ .../conversations/PriorityConversations.tsx | 4 +++ .../sessions/ConversationSidebar.tsx | 10 ++++++ .../channels/conversation_channel.ex | 23 ++++++++++++ .../channels/notification_channel.ex | 23 ++++++++++++ .../channels/conversation_channel_test.exs | 18 +++++++++- .../channels/notification_channel_test.exs | 15 ++++++++ 12 files changed, 169 insertions(+), 2 deletions(-) diff --git a/assets/src/components/conversations/AllConversations.tsx b/assets/src/components/conversations/AllConversations.tsx index e8d8e61dc..cb297607f 100644 --- a/assets/src/components/conversations/AllConversations.tsx +++ b/assets/src/components/conversations/AllConversations.tsx @@ -11,12 +11,14 @@ const AllConversations = () => { all = [], conversationsById = {}, messagesByConversation = {}, + othersTypingByConversation = {}, currentlyOnline = {}, fetchAllConversations, onSelectConversation, onUpdateConversation, onDeleteConversation, onSendMessage, + handleTyping, } = useConversations(); if (loading) { @@ -34,11 +36,13 @@ const AllConversations = () => { conversationIds={all} conversationsById={conversationsById} messagesByConversation={messagesByConversation} + othersTypingByConversation={othersTypingByConversation} fetch={fetchAllConversations} onSelectConversation={onSelectConversation} onUpdateConversation={onUpdateConversation} onDeleteConversation={onDeleteConversation} onSendMessage={onSendMessage} + handleTyping={handleTyping} /> ); }; diff --git a/assets/src/components/conversations/ClosedConversations.tsx b/assets/src/components/conversations/ClosedConversations.tsx index fe2b6cf97..e0d9d0172 100644 --- a/assets/src/components/conversations/ClosedConversations.tsx +++ b/assets/src/components/conversations/ClosedConversations.tsx @@ -11,6 +11,7 @@ const ClosedConversations = () => { closed = [], conversationsById = {}, messagesByConversation = {}, + othersTypingByConversation = {}, currentlyOnline = {}, fetchAllConversations, fetchClosedConversations, @@ -18,6 +19,7 @@ const ClosedConversations = () => { onUpdateConversation, onDeleteConversation, onSendMessage, + handleTyping, } = useConversations(); const fetch = async () => { @@ -44,11 +46,13 @@ const ClosedConversations = () => { conversationIds={closed} conversationsById={conversationsById} messagesByConversation={messagesByConversation} + othersTypingByConversation={othersTypingByConversation} fetch={fetch} onSelectConversation={onSelectConversation} onUpdateConversation={onUpdateConversation} onDeleteConversation={onDeleteConversation} onSendMessage={onSendMessage} + handleTyping={handleTyping} /> ); }; diff --git a/assets/src/components/conversations/ConversationFooter.tsx b/assets/src/components/conversations/ConversationFooter.tsx index 1ed1569c0..935dd5c5e 100644 --- a/assets/src/components/conversations/ConversationFooter.tsx +++ b/assets/src/components/conversations/ConversationFooter.tsx @@ -5,15 +5,21 @@ import {colors, Button, TextArea} from '../common'; const ConversationFooter = ({ sx = {}, onSendMessage, + onInputChanged, + othersTyping, }: { sx?: any; onSendMessage: (message: string) => void; + onInputChanged: () => void; + othersTyping: Array }) => { const [message, setMessage] = React.useState(''); const handleMessageChange = (e: any) => setMessage(e.target.value); const handleKeyDown = (e: any) => { + onInputChanged() + const {key, metaKey} = e; // Not sure what the best UX is here, but we currently allow // sending the message by pressing "cmd/metaKey + Enter" @@ -28,6 +34,21 @@ const ConversationFooter = ({ setMessage(''); }; + const othersTypingMessage = (others: Array) => { + const toStr = (name?: string, email?: string) => `${name || email || 'Anonymous User'} ` + + const titles = others.map((item: any) => toStr(item.name, item.email)); + + switch (titles.length) { + case 0: + return '\xa0'; + case 1: + return [...titles, 'is typing...'].join(' '); + default: + return `${titles.join(', ')} are typing...`; + } + }; + return ( @@ -58,6 +79,7 @@ const ConversationFooter = ({ + { othersTypingMessage(othersTyping) } ); diff --git a/assets/src/components/conversations/ConversationsContainer.tsx b/assets/src/components/conversations/ConversationsContainer.tsx index f20e51487..10b2b98c7 100644 --- a/assets/src/components/conversations/ConversationsContainer.tsx +++ b/assets/src/components/conversations/ConversationsContainer.tsx @@ -22,6 +22,7 @@ type Props = { conversationIds: Array; conversationsById: {[key: string]: Conversation}; messagesByConversation: {[key: string]: Array}; + othersTypingByConversation: {[key: string]: Array}; fetch: () => Promise>; onSelectConversation: (id: string | null, fn?: () => void) => void; onUpdateConversation: (id: string, params: any) => Promise; @@ -31,6 +32,7 @@ type Props = { conversationId: string, fn: () => void ) => void; + handleTyping: (conversationId: string) => () => void; }; type State = { @@ -42,7 +44,7 @@ type State = { class ConversationsContainer extends React.Component { scrollToEl: any = null; - state: State = {loading: true, selected: null, closing: []}; + state: State = {loading: true, selected: null, closing: [] }; componentDidMount() { const q = qs.parse(window.location.search); @@ -298,6 +300,9 @@ class ConversationsContainer extends React.Component { }); }; + othersTyping = (othersTypingByConversation: {[key: string]: Array}, conversationId: string) => + othersTypingByConversation[conversationId] || []; + render() { const {selected: selectedConversationId, closing = []} = this.state; const { @@ -426,6 +431,8 @@ class ConversationsContainer extends React.Component { )} diff --git a/assets/src/components/conversations/ConversationsProvider.tsx b/assets/src/components/conversations/ConversationsProvider.tsx index cab8d0883..9cf027f3b 100644 --- a/assets/src/components/conversations/ConversationsProvider.tsx +++ b/assets/src/components/conversations/ConversationsProvider.tsx @@ -22,6 +22,7 @@ export const ConversationsContext = React.createContext<{ conversationsById: {[key: string]: any}; messagesByConversation: {[key: string]: any}; currentlyOnline: {[key: string]: any}; + othersTypingByConversation: {[key: string]: any }; onSelectConversation: (id: string | null) => any; onUpdateConversation: (id: string, params: any) => Promise; @@ -38,6 +39,7 @@ export const ConversationsContext = React.createContext<{ fetchClosedConversations: () => Promise>; // TODO: should this be different? fetchConversationById: (conversationId: string) => Promise>; + handleTyping: (conversationId: string) => () => void; }>({ loading: true, account: null, @@ -52,6 +54,7 @@ export const ConversationsContext = React.createContext<{ conversationsById: {}, messagesByConversation: {}, currentlyOnline: {}, + othersTypingByConversation: {}, onSelectConversation: () => {}, onSendMessage: () => {}, @@ -62,6 +65,7 @@ export const ConversationsContext = React.createContext<{ fetchPriorityConversations: () => Promise.resolve([]), fetchClosedConversations: () => Promise.resolve([]), fetchConversationById: () => Promise.resolve([]), + handleTyping: () => () => {} }); export const useConversations = () => useContext(ConversationsContext); @@ -155,6 +159,7 @@ type State = { conversationsById: {[key: string]: any}; messagesByConversation: {[key: string]: any}; presence: PhoenixPresence; + othersTypingByConversation: {[key: string]: any}; all: Array; mine: Array; @@ -173,6 +178,7 @@ export class ConversationsProvider extends React.Component { conversationsById: {}, messagesByConversation: {}, presence: {}, + othersTypingByConversation: {}, all: [], mine: [], @@ -250,6 +256,25 @@ export class ConversationsProvider extends React.Component { this.handleNewMessage(message); }); + this.channel.on('message:other_typing', (payload) => { + const conversationId = payload.conversation_id; + + const oldState = this.state.othersTypingByConversation[conversationId] || []; + const typer = payload.user || payload.customer; + + const alreadyTyping = oldState.find((item: any) => item.id === typer.id && item.kind === typer.kind) + + const newState = typer && !alreadyTyping ? [...oldState, typer] : oldState; + + const updateTypingState = (conversationId: string, newState: Array) => + this.setState({ othersTypingByConversation: { ...this.state.othersTypingByConversation, [conversationId]: newState }}) + + if (typer.kind === "customer" || typer.id !== this.state.currentUser.id) { + updateTypingState(payload.conversation_id, newState); + setTimeout(() => updateTypingState(payload.conversation_id, []), 1000); + } + }); + // TODO: fix race condition between this event and `shout` above this.channel.on('conversation:created', ({id, conversation}) => { // Handle conversation created @@ -481,6 +506,13 @@ export class ConversationsProvider extends React.Component { } }; + handleTyping = (conversationId: string) => () => { + if (!this.channel || !this.state.currentUser) { + return; + } + this.channel.push('message:typing', { user_id: this.state.currentUser, conversation_id: conversationId }); + } + handleUpdateConversation = async (conversationId: string, params: any) => { const {conversationsById} = this.state; const existing = conversationsById[conversationId]; @@ -707,6 +739,7 @@ export class ConversationsProvider extends React.Component { conversationsById, messagesByConversation, presence, + othersTypingByConversation, } = this.state; const unreadByCategory = this.getUnreadByCategory(); @@ -717,6 +750,7 @@ export class ConversationsProvider extends React.Component { account, currentUser, isNewUser, + othersTypingByConversation, all, mine, priority, @@ -736,6 +770,7 @@ export class ConversationsProvider extends React.Component { fetchMyConversations: this.fetchMyConversations, fetchPriorityConversations: this.fetchPriorityConversations, fetchClosedConversations: this.fetchClosedConversations, + handleTyping: this.handleTyping, }} > {this.props.children} diff --git a/assets/src/components/conversations/MyConversations.tsx b/assets/src/components/conversations/MyConversations.tsx index 1d406f1d2..35d449e82 100644 --- a/assets/src/components/conversations/MyConversations.tsx +++ b/assets/src/components/conversations/MyConversations.tsx @@ -11,12 +11,14 @@ const MyConversations = () => { mine = [], conversationsById = {}, messagesByConversation = {}, + othersTypingByConversation={}, currentlyOnline = {}, fetchMyConversations, onSelectConversation, onUpdateConversation, onDeleteConversation, onSendMessage, + handleTyping, } = useConversations(); if (loading) { @@ -34,11 +36,13 @@ const MyConversations = () => { conversationIds={mine} conversationsById={conversationsById} messagesByConversation={messagesByConversation} + othersTypingByConversation={othersTypingByConversation} fetch={fetchMyConversations} onSelectConversation={onSelectConversation} onUpdateConversation={onUpdateConversation} onDeleteConversation={onDeleteConversation} onSendMessage={onSendMessage} + handleTyping={handleTyping} /> ); }; diff --git a/assets/src/components/conversations/PriorityConversations.tsx b/assets/src/components/conversations/PriorityConversations.tsx index 130d3ef9c..a80fe0fa5 100644 --- a/assets/src/components/conversations/PriorityConversations.tsx +++ b/assets/src/components/conversations/PriorityConversations.tsx @@ -11,12 +11,14 @@ const PriorityConversations = () => { priority = [], conversationsById = {}, messagesByConversation = {}, + othersTypingByConversation = {}, currentlyOnline = {}, fetchPriorityConversations, onSelectConversation, onUpdateConversation, onDeleteConversation, onSendMessage, + handleTyping, } = useConversations(); if (loading) { @@ -34,11 +36,13 @@ const PriorityConversations = () => { conversationIds={priority} conversationsById={conversationsById} messagesByConversation={messagesByConversation} + othersTypingByConversation={othersTypingByConversation} fetch={fetchPriorityConversations} onSelectConversation={onSelectConversation} onUpdateConversation={onUpdateConversation} onDeleteConversation={onDeleteConversation} onSendMessage={onSendMessage} + handleTyping={handleTyping} /> ); }; diff --git a/assets/src/components/sessions/ConversationSidebar.tsx b/assets/src/components/sessions/ConversationSidebar.tsx index b70ed78c6..8981f7ff4 100644 --- a/assets/src/components/sessions/ConversationSidebar.tsx +++ b/assets/src/components/sessions/ConversationSidebar.tsx @@ -10,11 +10,13 @@ type Props = { conversation: Conversation; currentUser: User; messages: Array; + othersTyping: Array; onSendMessage: ( message: string, conversationId: string, cb: () => void ) => void; + handleTyping: (conversationId: string) => () => void; }; class ConversationSidebar extends React.Component { @@ -77,6 +79,8 @@ class ConversationSidebar extends React.Component { ); @@ -93,9 +97,11 @@ const ConversationsSidebarWrapper = ({ currentUser, conversationsById = {}, messagesByConversation = {}, + othersTypingByConversation = {}, fetchConversationById, onSendMessage, onSelectConversation, + handleTyping, } = useConversations(); React.useEffect(() => { @@ -109,6 +115,8 @@ const ConversationsSidebarWrapper = ({ return null; } + const othersTyping = othersTypingByConversation[conversationId] || []; + // TODO: fix case where conversation is closed! const conversation = conversationsById[conversationId] || null; const messages = messagesByConversation[conversationId] || null; @@ -123,6 +131,8 @@ const ConversationsSidebarWrapper = ({ messages={messages} currentUser={currentUser} onSendMessage={onSendMessage} + handleTyping={handleTyping} + othersTyping={othersTyping} /> ); }; diff --git a/lib/chat_api_web/channels/conversation_channel.ex b/lib/chat_api_web/channels/conversation_channel.ex index e6ea1a17e..c62314337 100644 --- a/lib/chat_api_web/channels/conversation_channel.ex +++ b/lib/chat_api_web/channels/conversation_channel.ex @@ -108,6 +108,29 @@ defmodule ChatApiWeb.ConversationChannel do {:noreply, socket} end + @impl true + def handle_in("message:typing", payload, socket) do + with %{conversation: conversation, customer_id: customer_id} <- socket.assigns do + customer = ChatApi.Customers.get_customer!(customer_id) + + broadcast_from( + socket, + "message:other_typing", + %{ + customer: %{ + id: customer_id, + name: customer.name, + email: customer.email, + kind: "customer" + }, + conversation_id: conversation.id + } + ) + end + + {:noreply, socket} + end + defp broadcast_conversation_update(message) do %{conversation_id: conversation_id, account_id: account_id} = message # Mark as unread and ensure the conversation is open, since we want to diff --git a/lib/chat_api_web/channels/notification_channel.ex b/lib/chat_api_web/channels/notification_channel.ex index 1da5f6b9a..29d79ddca 100644 --- a/lib/chat_api_web/channels/notification_channel.ex +++ b/lib/chat_api_web/channels/notification_channel.ex @@ -68,6 +68,29 @@ defmodule ChatApiWeb.NotificationChannel do {:noreply, socket} end + @impl true + def handle_in("message:typing", %{"conversation_id" => conversation_id}, socket) do + with %{current_user: current_user} <- socket.assigns do + profile = ChatApi.Users.get_user_profile(current_user.id) + + ChatApiWeb.Endpoint.broadcast( + "conversation:#{conversation_id}", + "message:other_typing", + %{ + user: %{ + name: profile.display_name, + email: current_user.email, + id: current_user.id, + kind: "user" + }, + conversation_id: conversation_id + } + ) + end + + {:noreply, socket} + end + @impl true def handle_info(%Broadcast{topic: topic, event: event, payload: payload}, socket) do case topic do diff --git a/test/chat_api_web/channels/conversation_channel_test.exs b/test/chat_api_web/channels/conversation_channel_test.exs index 06d550687..753dcce8f 100644 --- a/test/chat_api_web/channels/conversation_channel_test.exs +++ b/test/chat_api_web/channels/conversation_channel_test.exs @@ -5,10 +5,12 @@ defmodule ChatApiWeb.ConversationChannelTest do setup do account = insert(:account) + customer = insert(:customer, account: account) + conversation = insert(:conversation, account: account, customer: customer) {:ok, _, socket} = ChatApiWeb.UserSocket - |> socket("user_id", %{some: :assign}) + |> socket("user_id", %{some: :assign, customer_id: customer.id, conversation: conversation}) |> subscribe_and_join(ChatApiWeb.ConversationChannel, "conversation:lobby") %{socket: socket, account: account} @@ -29,4 +31,18 @@ defmodule ChatApiWeb.ConversationChannelTest do broadcast_from!(socket, "broadcast", %{"some" => "data"}) assert_push "broadcast", %{"some" => "data"} end + + test "should handle typing event", %{socket: socket} do + push(socket, "message:typing", %{}) + customer = socket.assigns.customer_id |> ChatApi.Customers.get_customer!() + name = customer.name + email = customer.email + id = customer.id + conversation_id = socket.assigns.conversation.id + + assert_broadcast "message:other_typing", %{ + customer: %{id: ^id, name: ^name, email: ^email, kind: "customer"}, + conversation_id: ^conversation_id + } + end end diff --git a/test/chat_api_web/channels/notification_channel_test.exs b/test/chat_api_web/channels/notification_channel_test.exs index b86f1e8be..197a25eba 100644 --- a/test/chat_api_web/channels/notification_channel_test.exs +++ b/test/chat_api_web/channels/notification_channel_test.exs @@ -42,4 +42,19 @@ defmodule ChatApiWeb.NotificationChannelTest do broadcast_from!(socket, "broadcast", %{"some" => "data"}) assert_push "broadcast", %{"some" => "data"} end + + test "should handle typing event", %{socket: socket, conversation: conversation} do + profile = socket.assigns.current_user.id |> ChatApi.Users.get_user_profile() + name = profile.display_name + email = socket.assigns.current_user.email + id = socket.assigns.current_user.id + conversation_id = conversation.id + + push(socket, "message:typing", %{"conversation_id" => conversation.id}) + + assert_push "message:other_typing", %{ + user: %{id: ^id, name: ^name, email: ^email, kind: "user"}, + conversation_id: ^conversation_id + } + end end