Skip to content

Commit

Permalink
Fixes #164 - Add typing indicator
Browse files Browse the repository at this point in the history
Adds an indicator when other users in conversation are typing.
  • Loading branch information
xprazak2 committed Jan 1, 2021
1 parent 4564f87 commit c599ea9
Show file tree
Hide file tree
Showing 12 changed files with 169 additions and 2 deletions.
4 changes: 4 additions & 0 deletions assets/src/components/conversations/AllConversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ const AllConversations = () => {
all = [],
conversationsById = {},
messagesByConversation = {},
othersTypingByConversation = {},
currentlyOnline = {},
fetchAllConversations,
onSelectConversation,
onUpdateConversation,
onDeleteConversation,
onSendMessage,
handleTyping,
} = useConversations();

if (loading) {
Expand All @@ -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}
/>
);
};
Expand Down
4 changes: 4 additions & 0 deletions assets/src/components/conversations/ClosedConversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ const ClosedConversations = () => {
closed = [],
conversationsById = {},
messagesByConversation = {},
othersTypingByConversation = {},
currentlyOnline = {},
fetchAllConversations,
fetchClosedConversations,
onSelectConversation,
onUpdateConversation,
onDeleteConversation,
onSendMessage,
handleTyping,
} = useConversations();

const fetch = async () => {
Expand All @@ -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}
/>
);
};
Expand Down
22 changes: 22 additions & 0 deletions assets/src/components/conversations/ConversationFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>
}) => {
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"
Expand All @@ -28,6 +34,21 @@ const ConversationFooter = ({
setMessage('');
};

const othersTypingMessage = (others: Array<any>) => {
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 (
<Box style={{flex: '0 0 auto'}}>
<Box sx={{bg: colors.white, px: 4, pt: 0, pb: 4, ...sx}}>
Expand Down Expand Up @@ -58,6 +79,7 @@ const ConversationFooter = ({
</Flex>
</form>
</Box>
<Box sx={{ position: 'absolute' }}>{ othersTypingMessage(othersTyping) }</Box>
</Box>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Props = {
conversationIds: Array<string>;
conversationsById: {[key: string]: Conversation};
messagesByConversation: {[key: string]: Array<Message>};
othersTypingByConversation: {[key: string]: Array<object>};
fetch: () => Promise<Array<string>>;
onSelectConversation: (id: string | null, fn?: () => void) => void;
onUpdateConversation: (id: string, params: any) => Promise<void>;
Expand All @@ -31,6 +32,7 @@ type Props = {
conversationId: string,
fn: () => void
) => void;
handleTyping: (conversationId: string) => () => void;
};

type State = {
Expand All @@ -42,7 +44,7 @@ type State = {
class ConversationsContainer extends React.Component<Props, State> {
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);
Expand Down Expand Up @@ -298,6 +300,9 @@ class ConversationsContainer extends React.Component<Props, State> {
});
};

othersTyping = (othersTypingByConversation: {[key: string]: Array<object>}, conversationId: string) =>
othersTypingByConversation[conversationId] || [];

render() {
const {selected: selectedConversationId, closing = []} = this.state;
const {
Expand Down Expand Up @@ -426,6 +431,8 @@ class ConversationsContainer extends React.Component<Props, State> {
<ConversationFooter
key={selectedConversation.id}
onSendMessage={this.handleSendMessage}
onInputChanged={this.props.handleTyping(selectedConversation.id)}
othersTyping={this.othersTyping(this.props.othersTypingByConversation, selectedConversation.id)}
/>
)}

Expand Down
35 changes: 35 additions & 0 deletions assets/src/components/conversations/ConversationsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
Expand All @@ -38,6 +39,7 @@ export const ConversationsContext = React.createContext<{
fetchClosedConversations: () => Promise<Array<string>>;
// TODO: should this be different?
fetchConversationById: (conversationId: string) => Promise<Array<string>>;
handleTyping: (conversationId: string) => () => void;
}>({
loading: true,
account: null,
Expand All @@ -52,6 +54,7 @@ export const ConversationsContext = React.createContext<{
conversationsById: {},
messagesByConversation: {},
currentlyOnline: {},
othersTypingByConversation: {},

onSelectConversation: () => {},
onSendMessage: () => {},
Expand All @@ -62,6 +65,7 @@ export const ConversationsContext = React.createContext<{
fetchPriorityConversations: () => Promise.resolve([]),
fetchClosedConversations: () => Promise.resolve([]),
fetchConversationById: () => Promise.resolve([]),
handleTyping: () => () => {}
});

export const useConversations = () => useContext(ConversationsContext);
Expand Down Expand Up @@ -155,6 +159,7 @@ type State = {
conversationsById: {[key: string]: any};
messagesByConversation: {[key: string]: any};
presence: PhoenixPresence;
othersTypingByConversation: {[key: string]: any};

all: Array<string>;
mine: Array<string>;
Expand All @@ -173,6 +178,7 @@ export class ConversationsProvider extends React.Component<Props, State> {
conversationsById: {},
messagesByConversation: {},
presence: {},
othersTypingByConversation: {},

all: [],
mine: [],
Expand Down Expand Up @@ -250,6 +256,25 @@ export class ConversationsProvider extends React.Component<Props, State> {
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<object>) =>
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
Expand Down Expand Up @@ -481,6 +506,13 @@ export class ConversationsProvider extends React.Component<Props, State> {
}
};

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];
Expand Down Expand Up @@ -707,6 +739,7 @@ export class ConversationsProvider extends React.Component<Props, State> {
conversationsById,
messagesByConversation,
presence,
othersTypingByConversation,
} = this.state;
const unreadByCategory = this.getUnreadByCategory();

Expand All @@ -717,6 +750,7 @@ export class ConversationsProvider extends React.Component<Props, State> {
account,
currentUser,
isNewUser,
othersTypingByConversation,
all,
mine,
priority,
Expand All @@ -736,6 +770,7 @@ export class ConversationsProvider extends React.Component<Props, State> {
fetchMyConversations: this.fetchMyConversations,
fetchPriorityConversations: this.fetchPriorityConversations,
fetchClosedConversations: this.fetchClosedConversations,
handleTyping: this.handleTyping,
}}
>
{this.props.children}
Expand Down
4 changes: 4 additions & 0 deletions assets/src/components/conversations/MyConversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ const MyConversations = () => {
mine = [],
conversationsById = {},
messagesByConversation = {},
othersTypingByConversation={},
currentlyOnline = {},
fetchMyConversations,
onSelectConversation,
onUpdateConversation,
onDeleteConversation,
onSendMessage,
handleTyping,
} = useConversations();

if (loading) {
Expand All @@ -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}
/>
);
};
Expand Down
4 changes: 4 additions & 0 deletions assets/src/components/conversations/PriorityConversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ const PriorityConversations = () => {
priority = [],
conversationsById = {},
messagesByConversation = {},
othersTypingByConversation = {},
currentlyOnline = {},
fetchPriorityConversations,
onSelectConversation,
onUpdateConversation,
onDeleteConversation,
onSendMessage,
handleTyping,
} = useConversations();

if (loading) {
Expand All @@ -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}
/>
);
};
Expand Down
10 changes: 10 additions & 0 deletions assets/src/components/sessions/ConversationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ type Props = {
conversation: Conversation;
currentUser: User;
messages: Array<Message>;
othersTyping: Array<any>;
onSendMessage: (
message: string,
conversationId: string,
cb: () => void
) => void;
handleTyping: (conversationId: string) => () => void;
};

class ConversationSidebar extends React.Component<Props, any> {
Expand Down Expand Up @@ -77,6 +79,8 @@ class ConversationSidebar extends React.Component<Props, any> {
<ConversationFooter
sx={{px: 3, pb: 3}}
onSendMessage={this.handleSendMessage}
onInputChanged={this.props.handleTyping(conversation.id)}
othersTyping={this.props.othersTyping}
/>
</Flex>
);
Expand All @@ -93,9 +97,11 @@ const ConversationsSidebarWrapper = ({
currentUser,
conversationsById = {},
messagesByConversation = {},
othersTypingByConversation = {},
fetchConversationById,
onSendMessage,
onSelectConversation,
handleTyping,
} = useConversations();

React.useEffect(() => {
Expand All @@ -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;
Expand All @@ -123,6 +131,8 @@ const ConversationsSidebarWrapper = ({
messages={messages}
currentUser={currentUser}
onSendMessage={onSendMessage}
handleTyping={handleTyping}
othersTyping={othersTyping}
/>
);
};
Expand Down
Loading

0 comments on commit c599ea9

Please sign in to comment.