Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

SignalChat: WPF & SignalR Chat Application

0.00/5 (No votes)
19 Apr 2018 4  
A WPF-MVVM chat application that uses SignalR for real-time communication
SignalR is a library that enables developers to create applications that have real-time communication functionality. This article is not an introduction to SignalR, but rather a look through of some of the code for SignalChat, a WPF-MVVM instant messaging application that uses SignalR for real-time communication. We look at how SignalChat connects to the server, logs into the server, sends a message, displaying a chat message, and receives a chat message.
Image 1

Introduction

SignalChat is a WPF-MVVM instant messaging application that uses SignalR for real-time communication. It enables the user to send text messages or images to other clients that are connected to and logged into the chat server. To check out the project code, you clone or download the project from GitHub.

SignalR

SignalR is a library that enables developers to create applications that have real-time communication functionality. This functionality makes it suitable for the development of an instant-messaging chat application, like SignalChat, as any new data that is available on the server can immediately be pushed to all or some of the connected clients. With SignalR, connected clients can also be made aware of the connection status of other clients. NOTE: This article is not an introduction to SignalR.

SignalChat Server

The SignalChat server is hosted in a console application – a process referred to as self-hosting – where the server hub, which is the core of the SignalR server, is defined and configured. Configuration is done in the Startup and Program classes.

using System;
using Microsoft.AspNet.SignalR;
using Microsoft.Owin.Cors;
using Microsoft.Owin.Hosting;
using Owin;


namespace SignalServer
{
    class Program
    {
        static void Main(string[] args)
        {
            var url = "http://localhost:8080/";
            using (WebApp.Start<Startup>(url))
            {
                Console.WriteLine($"Server running at {url}");
                Console.ReadLine();
            }
        }
    }

    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            app.UseCors(CorsOptions.AllowAll);
            app.MapSignalR("/signalchat", new HubConfiguration());
        }
    }
}
Imports Microsoft.AspNet.SignalR
Imports Microsoft.Owin.Cors
Imports Microsoft.Owin.Hosting
Imports Owin

Module ServerModule

    Sub Main()
        Dim url = "http://localhost:8080/"
        Using WebApp.Start(Of Startup)(url)
            Console.WriteLine($"Server running at {url}")
            Console.ReadLine()
        End Using
    End Sub

End Module

Public Class Startup
    Public Sub Configuration(app As IAppBuilder)
        app.UseCors(CorsOptions.AllowAll)
        app.MapSignalR("/signalchat", New HubConfiguration())
    End Sub
End Class

The server hub's full path will be http://localhost:8080/signalchat and it is configured for cross-domain communication.

The hub is the core of a SignalR server as it handles communication between the server and connected clients. The SignalChat server contains a single hub class, that is strongly-typed, which contains methods that deal with client connection, disconnection, login, logout, and sending of messages to all (broadcast) or a specific client (unicast). Note: The SignalChat clients only invokes the unicast methods when sending messages.

public class ChatHub : Hub<IClient>
{
    private static ConcurrentDictionary<string, User> ChatClients = 
                                           new ConcurrentDictionary<string, User>();

    public override Task OnDisconnected(bool stopCalled)
    {
        var userName = ChatClients.SingleOrDefault((c) => c.Value.ID == Context.ConnectionId).Key;
        if (userName != null)
        {
            Clients.Others.ParticipantDisconnection(userName);
            Console.WriteLine($"<> {userName} disconnected");
        }
        return base.OnDisconnected(stopCalled);
    }

    public override Task OnReconnected()
    {
        var userName = ChatClients.SingleOrDefault((c) => c.Value.ID == Context.ConnectionId).Key;
        if (userName != null)
        {
            Clients.Others.ParticipantReconnection(userName);
            Console.WriteLine($"== {userName} reconnected");
        }
        return base.OnReconnected();
    }

    public List<User> Login(string name, byte[] photo)
    {
        if (!ChatClients.ContainsKey(name))
        {
            Console.WriteLine($"++ {name} logged in");
            List<User> users = new List<User>(ChatClients.Values);
            User newUser = new User { Name = name, ID = Context.ConnectionId, Photo = photo };
            var added = ChatClients.TryAdd(name, newUser);
            if (!added) return null;
            Clients.CallerState.UserName = name;
            Clients.Others.ParticipantLogin(newUser);
            return users;
        }
        return null;
    }

    public void Logout()
    {
        var name = Clients.CallerState.UserName;
        if (!string.IsNullOrEmpty(name))
        {
            User client = new User();
            ChatClients.TryRemove(name, out client);
            Clients.Others.ParticipantLogout(name);
            Console.WriteLine($"-- {name} logged out");
        }
    }

    public void BroadcastTextMessage(string message)
    {
        var name = Clients.CallerState.UserName;
        if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(message))
        {
            Clients.Others.BroadcastTextMessage(name, message);
        }
    }

    public void BroadcastImageMessage(byte[] img)
    {
        var name = Clients.CallerState.UserName;
        if (img != null)
        {
            Clients.Others.BroadcastPictureMessage(name, img);
        }
    }

    public void UnicastTextMessage(string recepient, string message)
    {
        var sender = Clients.CallerState.UserName;
        if (!string.IsNullOrEmpty(sender) && recepient != sender &&
            !string.IsNullOrEmpty(message) && ChatClients.ContainsKey(recepient))
        {
            User client = new User();
            ChatClients.TryGetValue(recepient, out client);
            Clients.Client(client.ID).UnicastTextMessage(sender, message);
        }
    }

    public void UnicastImageMessage(string recepient, byte[] img)
    {
        var sender = Clients.CallerState.UserName;
        if (!string.IsNullOrEmpty(sender) && recepient != sender &&
            img != null && ChatClients.ContainsKey(recepient))
        {
            User client = new User();
            ChatClients.TryGetValue(recepient, out client);
            Clients.Client(client.ID).UnicastPictureMessage(sender, img);
        }
    }
    public void Typing(string recepient)
    {
        if (string.IsNullOrEmpty(recepient)) return;
        var sender = Clients.CallerState.UserName;
        User client = new User();
        ChatClients.TryGetValue(recepient, out client);
        Clients.Client(client.ID).ParticipantTyping(sender);
    }
}
Public Class ChatHub
    Inherits Hub(Of IClient)

    Private Shared ChatClients As New ConcurrentDictionary(Of String, User)

    Public Overrides Function OnDisconnected(stopCalled As Boolean) As Task
        Dim userName = ChatClients.SingleOrDefault(Function(c) c.Value.ID = Context.ConnectionId).Key
        If userName IsNot Nothing Then
            Clients.Others.ParticipantDisconnection(userName)
            Console.WriteLine($"<> {userName} disconnected")
        End If
        Return MyBase.OnDisconnected(stopCalled)
    End Function

    Public Overrides Function OnReconnected() As Task
        Dim userName = ChatClients.SingleOrDefault(Function(c) c.Value.ID = Context.ConnectionId).Key
        If userName IsNot Nothing Then
            Clients.Others.ParticipantReconnection(userName)
            Console.WriteLine($"== {userName} reconnected")
        End If
        Return MyBase.OnReconnected()
    End Function

    Public Function Login(ByVal name As String, ByVal photo As Byte()) As List(Of User)
        If Not ChatClients.ContainsKey(name) Then
            Console.WriteLine($"++ {name} logged in")
            Dim users As New List(Of User)(ChatClients.Values)
            Dim newUser As New User With {.Name = name, .ID = Context.ConnectionId, .Photo = photo}
            Dim added = ChatClients.TryAdd(name, newUser)
            If Not added Then Return Nothing
            Clients.CallerState.UserName = name
            Clients.Others.ParticipantLogin(newUser)
            Return users
        End If
        Return Nothing
    End Function

    Public Sub Logout()
        Dim name = Clients.CallerState.UserName
        If Not String.IsNullOrEmpty(name) Then
            Dim client As New User
            ChatClients.TryRemove(name, client)
            Clients.Others.ParticipantLogout(name)
            Console.WriteLine($"-- {name} logged out")
        End If
    End Sub

    Public Sub BroadcastChat(message As String)
        Dim name = Clients.CallerState.UserName
        If Not String.IsNullOrEmpty(name) AndAlso Not String.IsNullOrEmpty(message) Then
            Clients.Others.BroadcastMessage(name, message)
        End If
    End Sub

    Public Sub UnicastChat(recepient As String, message As String)
        Dim sender = Clients.CallerState.UserName
        If Not String.IsNullOrEmpty(sender) AndAlso recepient <> sender AndAlso
           Not String.IsNullOrEmpty(message) AndAlso ChatClients.Keys.Contains(recepient) Then
            Dim client As New User
            ChatClients.TryGetValue(recepient, client)
            Clients.Client(client.ID).UnicastMessage(sender, message)
        End If
    End Sub
    
    Public Sub Typing(recepient As String)
        If String.IsNullOrEmpty(recepient) Then Exit Sub
        Dim sender = Clients.CallerState.UserName
        Dim client As New User
        ChatClients.TryGetValue(recepient, client)
        Clients.Client(client.ID).ParticipantTyping(sender)
    End Sub    
End Class

Client.CallerState is a dynamic property that is used to store state. When a client logs in a property named UserName is added to the dynamic object and is used to check which client is calling a particular method.

SignalChat Client

Connecting to the Server

The client application connects to the chat server when the application is opened. This is done using an InvokeCommandAction which executes an ICommand when the application window is loaded.

<i:Interaction.Triggers>
    <i:EventTrigger>
        <i:InvokeCommandAction Command="{Binding ConnectCommand}"/>
    </i:EventTrigger>
    ...
</i:Interaction.Triggers>

The ConnectCommand, that is defined in MainWindowViewModel, executes an asynchronous function that calls a method of an IChatService implementation – IChatService specifies events and methods that can be triggered/called to enable interaction with the server.

private ICommand _connectCommand;
public ICommand ConnectCommand
{
    get
    {
        if (_connectCommand == null) _connectCommand = new RelayCommandAsync(() => Connect());
        return _connectCommand;
    }
}

private async Task<bool> Connect()
{
    try
    {
        await chatService.ConnectAsync();
        IsConnected = true;
        return true;
    }
    catch (Exception) { return false; }
}
Private _connectCommand As ICommand
Public ReadOnly Property ConnectCommand As ICommand
    Get
        Return If(_connectCommand, New RelayCommandAsync(Function() Connect()))
    End Get
End Property

Private Async Function Connect() As Task(Of Boolean)
    Try
        Await ChatService.ConnectAsync()
        IsConnected = True
        Return True
    Catch ex As Exception
        Return False
    End Try
End Function

In ConnectAsync(), an instance of a HubConnection and a proxy for the server hub are created. The proxy is used to specify which events the client app will deal with.

public async Task ConnectAsync()
{
    connection = new HubConnection(url);
    hubProxy = connection.CreateHubProxy("ChatHub");
    hubProxy.On<User>("ParticipantLogin", (u) => ParticipantLoggedIn?.Invoke(u));
    hubProxy.On<string>("ParticipantLogout", (n) => ParticipantLoggedOut?.Invoke(n));
    hubProxy.On<string>("ParticipantDisconnection", (n) => ParticipantDisconnected?.Invoke(n));
    hubProxy.On<string>("ParticipantReconnection", (n) => ParticipantReconnected?.Invoke(n));
    hubProxy.On<string, string>("BroadcastTextMessage", 
             (n, m) => NewTextMessage?.Invoke(n, m, MessageType.Broadcast));
    hubProxy.On<string, byte[]>("BroadcastPictureMessage", 
             (n, m) => NewImageMessage?.Invoke(n, m, MessageType.Broadcast));
    hubProxy.On<string, string>("UnicastTextMessage", 
             (n, m) => NewTextMessage?.Invoke(n, m, MessageType.Unicast));
    hubProxy.On<string, byte[]>("UnicastPictureMessage", 
             (n, m) => NewImageMessage?.Invoke(n, m, MessageType.Unicast));
    hubProxy.On<string>("ParticipantTyping", (p) => ParticipantTyping?.Invoke(p)); 
                
    connection.Reconnecting += Reconnecting;
    connection.Reconnected += Reconnected;
    connection.Closed += Disconnected;

    ServicePointManager.DefaultConnectionLimit = 10;
    await connection.Start();
}
Public Async Function ConnectAsync() As Task Implements IChatService.ConnectAsync
    connection = New HubConnection(url)
    hubProxy = connection.CreateHubProxy("ChatHub")
    hubProxy.On(Of User)("ParticipantLogin", Sub(u) RaiseEvent ParticipantLoggedIn(u))
    hubProxy.On(Of String)("ParticipantLogout", Sub(n) RaiseEvent ParticipantLoggedOut(n))
    hubProxy.On(Of String)("ParticipantDisconnection", Sub(n) RaiseEvent ParticipantDisconnected(n))
    hubProxy.On(Of String)("ParticipantReconnection", Sub(n) RaiseEvent ParticipantReconnected(n))
    hubProxy.On(Of String, String)("BroadcastTextMessage",
                           Sub(n, m) RaiseEvent NewTextMessage(n, m, MessageType.Broadcast))
    hubProxy.On(Of String, Byte())("BroadcastPictureMessage",
                           Sub(n, m) RaiseEvent NewImageMessage(n, m, MessageType.Broadcast))
    hubProxy.On(Of String, String)("UnicastTextMessage",
                           Sub(n, m) RaiseEvent NewTextMessage(n, m, MessageType.Unicast))
    hubProxy.On(Of String, Byte())("UnicastPictureMessage",
                           Sub(n, m) RaiseEvent NewImageMessage(n, m, MessageType.Unicast))
    hubProxy.On(Of String)("ParticipantTyping", Sub(p) RaiseEvent ParticipantTyping(p))
                               
    AddHandler connection.Reconnecting, AddressOf Reconnecting
    AddHandler connection.Reconnected, AddressOf Reconnected
    AddHandler connection.Closed, AddressOf Disconnected

    ServicePointManager.DefaultConnectionLimit = 10
    Await connection.Start()
End Function

Logging into the Server

The login button is enabled when the user enters a name in the username TextBox. The button executes LoginCommand when clicked.

private ICommand _loginCommand;
public ICommand LoginCommand
{
    get
    {
        if (_loginCommand == null) _loginCommand = new RelayCommandAsync(() => Login(), 
                                 (o) => CanLogin());
        return _loginCommand;
    }
}

private async Task<bool> Login()
{
    try
    {
        List<User> users = new List<User>();
        users = await chatService.LoginAsync(_userName, Avatar());
        if (users != null)
        {
            users.ForEach(u => Participants.Add(new Participant { Name = u.Name, Photo = u.Photo }));
            UserMode = UserModes.Chat;
            IsLoggedIn = true;
            return true;
        }
        else
        {
            dialogService.ShowNotification("Username is already in use");
            return false;
        }

    }
    catch (Exception) { return false; }
}

private bool CanLogin()
{
    return !string.IsNullOrEmpty(UserName) && UserName.Length >= 2 && IsConnected;
}
Private _loginCommand As ICommand
Public ReadOnly Property LoginCommand As ICommand
    Get
        Return If(_loginCommand, New RelayCommandAsync(Function() Login(), AddressOf CanLogin))
    End Get
End Property

Private Async Function Login() As Task(Of Boolean)
    Try
        Dim users As List(Of User)
        users = Await ChatService.LoginAsync(_userName, Avatar())
        If users IsNot Nothing Then
            users.ForEach(Sub(u) Participants.Add_
                  (New Participant With {.Name = u.Name, .Photo = u.Photo}))
            UserMode = UserModes.Chat
            IsLoggedIn = True
            Return True
        Else
            DialogService.ShowNotification("Username is already in use")
            Return False
        End If
    Catch ex As Exception
        Return False
    End Try
End Function

Private Function CanLogin() As Boolean
    Return Not String.IsNullOrEmpty(UserName) AndAlso UserName.Length >= 2 AndAlso IsConnected
End Function

LoginAsync() invokes the Login() method on the server side hub. If a login is successful, a collection of User objects, containing the details of other clients that are already connected and logged into the server, is returned.

public async Task<List<User>> LoginAsync(string name, byte[] photo)
{
    return await hubProxy.Invoke<List<User>>("Login", new object[] { name, photo });
}
Public Async Function LoginAsync(name As String, photo As Byte()) _
             As Task(Of List(Of User)) Implements IChatService.LoginAsync
    Dim users = Await hubProxy.Invoke(Of List(Of User))("Login", New Object() {name, photo})
    Return users
End Function

These clients are displayed in a ListBox whose ItemsSource property is bound to a collection of Participant objects.

<Border Grid.RowSpan="2" BorderThickness="0,0,1,0" SnapsToDevicePixels="True"

        BorderBrush="{DynamicResource MaterialDesignDivider}">
    <ListBox ItemsSource="{Binding Participants}" 

             ItemTemplate="{DynamicResource ParticipantsDataTemplate}"

             ItemContainerStyle="{DynamicResource ParticipantsListBoxItemStyle}"

             SelectedItem="{Binding SelectedParticipant}" 

             ScrollViewer.HorizontalScrollBarVisibility="Disabled"

             ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</Border>

The ParticipantsDataTemplate contains elements that display the client's profile picture, name, connection status, and whether the client has sent a new message that the user hasn't seen.

<DataTemplate x:Key="ParticipantsDataTemplate">
    <Border BorderThickness="0,0,0,1" BorderBrush="{DynamicResource MaterialDesignDivider}"

            Width="{Binding Path=ActualWidth, 
            RelativeSource={RelativeSource FindAncestor, AncestorType=ListBoxItem}}"

            Height="50" Margin="-2,0,0,0">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="5"/>
                <ColumnDefinition Width="50"/>
                <ColumnDefinition/>
                <ColumnDefinition Width="15"/>
                <ColumnDefinition Width="25"/>
            </Grid.ColumnDefinitions>
            <Rectangle x:Name="ParticipantRct" Fill="{DynamicResource PrimaryHueMidBrush}"

                       Visibility="Hidden"/>
            <!--Pic-->
            <Grid Grid.Column="1" Margin="6" SnapsToDevicePixels="True">
                <Grid.OpacityMask>
                    <VisualBrush Visual="{Binding ElementName=ClipEllipse}"/>
                </Grid.OpacityMask>
                <Ellipse x:Name="ClipEllipse" Fill="White"/>
                <materialDesign:PackIcon Kind="AccountCircle" 

                                SnapsToDevicePixels="True" Width="Auto" Height="Auto"

                                         Margin="-4" HorizontalAlignment="Stretch" 

                                         VerticalAlignment="Stretch"/>
                <Image Source="{Binding Photo, Converter={StaticResource ByteBmpSrcConverter},
                    TargetNullValue={StaticResource BlankImage}}" Stretch="UniformToFill"/>
            </Grid>
            <!--Name-->
            <TextBlock Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Left"

                       Margin="5,0" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"

                       Text="{Binding Name}" SnapsToDevicePixels="True"/>

            <!--New Message icon-->
            <materialDesign:PackIcon Name="NewMessageIcon" Grid.Column="3" SnapsToDevicePixels="True"

                                     VerticalAlignment="Center" HorizontalAlignment="Center"

                                     Kind="MessageReplyText" Opacity="0.8" Visibility="Hidden"/>
                                     
            <!--Typing icon-->
            <materialDesign:PackIcon Name="TypingIcon" Grid.Column="3" SnapsToDevicePixels="True"

                                     VerticalAlignment="Center" HorizontalAlignment="Center"

                                     Kind="Feather" Opacity="0.8" Visibility="Hidden"/>
                                                                          
            <!--Online-->
            <Ellipse Grid.Column="4" VerticalAlignment="Center" HorizontalAlignment="Center" 

                     Width="8" Height="8">
                <Ellipse.Style>
                    <Style TargetType="Ellipse">
                        <Setter Property="Fill" Value="#F44336"/>
                        <Style.Triggers>
                            <MultiDataTrigger>
                                <MultiDataTrigger.Conditions>
                                    <Condition Binding="{Binding DataContext.IsConnected, 
                                        RelativeSource={RelativeSource FindAncestor, 
                                        AncestorType=UserControl}}" Value="True"/>
                                    <Condition Binding="{Binding IsLoggedIn}" Value="True"/>
                                </MultiDataTrigger.Conditions>
                                <MultiDataTrigger.Setters>
                                    <Setter Property="Fill" Value="#64DD17"/>
                                </MultiDataTrigger.Setters>
                            </MultiDataTrigger>
                        </Style.Triggers>
                    </Style>
                </Ellipse.Style>
            </Ellipse>
        </Grid>
    </Border>
    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding HasSentNewMessage}" Value="True">
            <Setter TargetName="NewMessageIcon" Property="Visibility" Value="Visible"/>
        </DataTrigger>
        <DataTrigger Binding="{Binding IsTyping}" Value="True">
            <Setter TargetName="NewMessageIcon" Property="Visibility" Value="Hidden"/>
            <Setter TargetName="TypingIcon" Property="Visibility" Value="Visible"/>            
        </DataTrigger>        
        <DataTrigger Binding="{Binding Path=IsSelected,
            RelativeSource={RelativeSource FindAncestor, AncestorType=ListBoxItem}}" Value="true">
            <Setter Property="Visibility" TargetName="ParticipantRct" Value="Visible"/>
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>

Sending a Message

The user can send either a text or image message. To send a text message, the user has to select a client in the client's list and type some text, which enables the send button. The SendTextMessageCommand is executed when the send button is clicked.

private ICommand _sendTextMessageCommand;
public ICommand SendTextMessageCommand
{
    get
    {
        if (_sendTextMessageCommand == null) _sendTextMessageCommand =
                new RelayCommandAsync(() => SendTextMessage(), (o) => CanSendTextMessage());
        return _sendTextMessageCommand;
    }
}

private async Task<bool> SendTextMessage()
{
    try
    {
        var recepient = _selectedParticipant.Name;
        await chatService.SendUnicastMessageAsync(recepient, _textMessage);
        return true;
    }
    catch (Exception) { return false; }
    finally
    {
        ChatMessage msg = new ChatMessage
        {
            Author = UserName,
            Message = _textMessage,
            Time = DateTime.Now,
            IsOriginNative = true
        };
        SelectedParticipant.Chatter.Add(msg);
        TextMessage = string.Empty;
    }
}

private bool CanSendTextMessage()
{
    return (!string.IsNullOrEmpty(TextMessage) && IsConnected &&
        _selectedParticipant != null && _selectedParticipant.IsLoggedIn);
}
Private _sendTextMessageCommand As ICommand
Public ReadOnly Property SendTextMessageCommand As ICommand
    Get
        Return If(_sendTextMessageCommand, New RelayCommandAsync(Function() SendTextMessage(),
                                                                     AddressOf CanSendTextMessage))
    End Get
End Property

Private Async Function SendTextMessage() As Task(Of Boolean)
    Try
        Dim recepient = _selectedParticipant.Name
        Await ChatService.SendUnicastMessageAsync(recepient, _textMessage)
        Return True
    Catch ex As Exception
        Return False
    Finally
        Dim msg As New ChatMessage With {.Author = UserName, .Message = _textMessage,
                .Time = DateTime.Now, .IsOriginNative = True}
        SelectedParticipant.Chatter.Add(msg)
        TextMessage = String.Empty
    End Try
End Function

Private Function CanSendTextMessage() As Boolean
    Return Not String.IsNullOrEmpty(TextMessage) AndAlso IsConnected AndAlso
            _selectedParticipant IsNot Nothing AndAlso _selectedParticipant.IsLoggedIn
End Function

To send an image, the user clicks on the image button in the chat screen which executes the SendImageMessageCommand.

private ICommand _sendImageMessageCommand;
public ICommand SendImageMessageCommand
{
    get
    {
        if (_sendImageMessageCommand == null) _sendImageMessageCommand =
                new RelayCommandAsync(() => SendImageMessage(), (o) => CanSendImageMessage());
        return _sendImageMessageCommand;
    }
}

private async Task<bool> SendImageMessage()
{
    var pic = dialogService.OpenFile("Select image file", "Images (*.jpg;*.png)|*.jpg;*.png");
    if (string.IsNullOrEmpty(pic)) return false;

    var img = await Task.Run(() => File.ReadAllBytes(pic));

    try
    {
        var recepient = _selectedParticipant.Name;
        await chatService.SendUnicastMessageAsync(recepient, img);
        return true;
    }
    catch (Exception) { return false; }
    finally
    {
        ChatMessage msg = new ChatMessage 
           { Author = UserName, Picture = pic, Time = DateTime.Now, IsOriginNative = true };
        SelectedParticipant.Chatter.Add(msg);
    }
}

private bool CanSendImageMessage()
{
    return (IsConnected && _selectedParticipant != null && _selectedParticipant.IsLoggedIn);
}
Private _sendImageMessageCommand As ICommand
Public ReadOnly Property SendImageMessageCommand As ICommand
    Get
        Return If(_sendImageMessageCommand, New RelayCommandAsync(Function() SendImageMessage(),_
                                                                  AddressOf CanSendImageMessage))
    End Get
End Property

Private Async Function SendImageMessage() As Task(Of Boolean)
    Dim pic = DialogService.OpenFile("Select image file", "Images (*.jpg;*.png)|*.jpg;*.png")
    If String.IsNullOrEmpty(pic) Then Return False

    Dim img = Await Task.Run(Function() File.ReadAllBytes(pic))

    Try
        Dim recepient = _selectedParticipant.Name
        Await ChatService.SendUnicastMessageAsync(recepient, img)
        Return True
    Catch ex As Exception
        Return False
    Finally
        Dim msg As New ChatMessage With {.Author = UserName, .Picture = pic, _
                                         .Time = DateTime.Now, .IsOriginNative = True}
        SelectedParticipant.Chatter.Add(msg)
    End Try
End Function

Private Function CanSendImageMessage() As Boolean
    Return IsConnected AndAlso _selectedParticipant IsNot Nothing _
                       AndAlso _selectedParticipant.IsLoggedIn
End Function

Displaying Chat Messages

Chat messages are displayed in an ItemsControl whose ItemsSource property is bound to a collection of ChatMessage objects.

<ItemsControl x:Name="MessagesItemsCtrl" Grid.Column="1" Margin="0,5,0,0"

              ItemsSource="{Binding SelectedParticipant.Chatter}" 

              ItemTemplate="{DynamicResource MessagesDataTemplate}"

              ScrollViewer.VerticalScrollBarVisibility="Auto">
    <i:Interaction.Behaviors>
        <utils:BringNewItemIntoViewBehavior/>
    </i:Interaction.Behaviors>
    <ItemsControl.Template>
        <ControlTemplate TargetType="ItemsControl">
            <ScrollViewer>
                <ItemsPresenter/>
            </ScrollViewer>
        </ControlTemplate>
    </ItemsControl.Template>
</ItemsControl>

The elements that display the chat messages are defined in MessagesDataTemplate.

<DataTemplate x:Key="MessagesDataTemplate">
    <Border Name="MessageBorder" MinHeight="40" MinWidth="280" BorderThickness="1" Background="#EFEBE9" 

            Margin="10,0,60,10" BorderBrush="#BCAAA4" CornerRadius="4" SnapsToDevicePixels="True"

            HorizontalAlignment="Left">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition/>
                <RowDefinition Height="15"/>
            </Grid.RowDefinitions>
            <Grid x:Name="ImageMessageGrid" Margin="6,6,6,5">
                <Grid.OpacityMask>
                    <VisualBrush Visual="{Binding ElementName=ClipBorder}"/>
                </Grid.OpacityMask>
                <Border x:Name="ClipBorder" CornerRadius="3" Background="White"/>
                <Image Stretch="UniformToFill" Cursor="Hand" 

                               ToolTip="Click to open image in your default image viewer"

                               Source="{Binding Picture}">
                    <i:Interaction.Triggers>
                        <i:EventTrigger EventName="MouseLeftButtonUp">
                            <i:InvokeCommandAction

                                Command="{Binding DataContext.OpenImageCommand, 
                                RelativeSource={RelativeSource FindAncestor, AncestorType=UserControl}}"

                                CommandParameter="{Binding}"/>
                        </i:EventTrigger>
                    </i:Interaction.Triggers>
                </Image>
            </Grid>
            <TextBlock x:Name="MessageTxtBlock" Grid.Row="1" Margin="7,5,7,0" TextWrapping="Wrap"

                       VerticalAlignment="Stretch" HorizontalAlignment="Stretch" 

                       Text="{Binding Message}"/>
            <TextBlock Grid.Row="2" HorizontalAlignment="Right" VerticalAlignment="Stretch"

                       Margin="0,0,5,0" FontSize="10" Opacity="0.8"

                       Text="{Binding Time, StringFormat={}{0:t}}"/>
        </Grid>
    </Border>
    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding IsOriginNative}" Value="True">
            <Setter TargetName="MessageBorder" Property="HorizontalAlignment" Value="Right"/>
            <Setter TargetName="MessageBorder" Property="Margin" Value="60,0,10,10"/>
            <Setter TargetName="MessageBorder" Property="Background" Value="#BBDEFB"/>
            <Setter TargetName="MessageBorder" Property="BorderBrush" Value="#64B5F6"/>
        </DataTrigger>
        <DataTrigger Binding="{Binding Picture}" Value="{x:Null}">
            <Setter TargetName="ImageMessageGrid" Property="Visibility" Value="Collapsed"/>
        </DataTrigger>
        <DataTrigger Binding="{Binding Message}" Value="{x:Null}">
            <Setter TargetName="MessageTxtBlock" Property="Visibility" Value="Collapsed"/>
            <Setter TargetName="MessageBorder" Property="MaxWidth" Value="320"/>
        </DataTrigger>
        <MultiDataTrigger>
            <MultiDataTrigger.Conditions>
                <Condition Binding="{Binding Message}" Value="{x:Null}"/>
                <Condition Binding="{Binding IsOriginNative}" Value="True"/>
            </MultiDataTrigger.Conditions>
            <MultiDataTrigger.Setters>
                <Setter TargetName="MessageBorder" Property="Margin" Value="0,0,10,10"/>
                <Setter TargetName="MessageBorder" Property="HorizontalAlignment" Value="Right"/>
            </MultiDataTrigger.Setters>
        </MultiDataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>

Notice that clicking on an image will execute an ICommand. The OpenImageCommand calls a method that opens the image in the user's default image viewer.

private ICommand _openImageCommand;
public ICommand OpenImageCommand
{
    get
    {
        if (_openImageCommand == null) _openImageCommand = _
                         new RelayCommand<ChatMessage>((m) => OpenImage(m));
        return _openImageCommand;
    }
}

private void OpenImage(ChatMessage msg)
{
    var img = msg.Picture;
    if (string.IsNullOrEmpty(img) || !File.Exists(img)) return;
    Process.Start(img);
}
Private _openImageCommand As ICommand
Public ReadOnly Property OpenImageCommand As ICommand
    Get
        Return If(_openImageCommand, New RelayCommand(Of ChatMessage)(Sub(m) OpenImage(m)))
    End Get
End Property

Private Sub OpenImage(ByVal msg As ChatMessage)
    Dim img = msg.Picture
    If (String.IsNullOrEmpty(img) OrElse Not File.Exists(img)) Then Exit Sub
    Process.Start(img)
End Sub

Receiving Messages

When a text message is received from the server, the NewTextMessage() method is called. NewTextMessage() is set as the event handler for the IChatService.NewTextMessage event in MainWindowViewModel's constructor.

private void NewTextMessage(string name, string msg, MessageType mt)
{
    if (mt == MessageType.Unicast)
    {
        ChatMessage cm = new ChatMessage { Author = name, Message = msg, Time = DateTime.Now };
        var sender = _participants.Where((u) => string.Equals(u.Name, name)).FirstOrDefault();
        ctxTaskFactory.StartNew(() => sender.Chatter.Add(cm)).Wait();

        if (!(SelectedParticipant != null && sender.Name.Equals(SelectedParticipant.Name)))
        {
            ctxTaskFactory.StartNew(() => sender.HasSentNewMessage = true).Wait();
        }
    }
}
Private Sub NewTextMessage(name As String, msg As String, mt As MessageType)
    If mt = MessageType.Unicast Then
        Dim cm As New ChatMessage With {.Author = name, .Message = msg, .Time = DateTime.Now}
        Dim sender = _participants.Where(Function(u) String.Equals(u.Name, name)).FirstOrDefault
        ctxTaskFactory.StartNew(Sub() sender.Chatter.Add(cm)).Wait()

        If Not (SelectedParticipant IsNot Nothing AndAlso sender.Name.Equals_
                      (SelectedParticipant.Name)) Then
            ctxTaskFactory.StartNew(Sub() sender.HasSentNewMessage = True).Wait()
        End If
    End If
End Sub

Image messages are handled by NewImageMessage().

private void NewImageMessage(string name, byte[] pic, MessageType mt)
{
    if (mt == MessageType.Unicast)
    {
        var imgsDirectory = Path.Combine(Environment.CurrentDirectory, "Image Messages");
        if (!Directory.Exists(imgsDirectory)) Directory.CreateDirectory(imgsDirectory);

        var imgsCount = Directory.EnumerateFiles(imgsDirectory).Count() + 1;
        var imgPath = Path.Combine(imgsDirectory, $"IMG_{imgsCount}.jpg");

        ImageConverter converter = new ImageConverter();
        using (Image img = (Image)converter.ConvertFrom(pic))
        {
            img.Save(imgPath);
        }

        ChatMessage cm = new ChatMessage { Author = name, Picture = imgPath, Time = DateTime.Now };
        var sender = _participants.Where(u => string.Equals(u.Name, name)).FirstOrDefault();
        ctxTaskFactory.StartNew(() => sender.Chatter.Add(cm)).Wait();

        if (!(SelectedParticipant != null && sender.Name.Equals(SelectedParticipant.Name)))
        {
            ctxTaskFactory.StartNew(() => sender.HasSentNewMessage = true).Wait();
        }
    }
}
Private Sub NewImageMessage(name As String, pic As Byte(), mt As MessageType)
    If mt = MessageType.Unicast Then
        Dim imgsDirectory = Path.Combine(Environment.CurrentDirectory, "Image Messages")
        If Not Directory.Exists(imgsDirectory) Then Directory.CreateDirectory(imgsDirectory)

        Dim imgsCount = Directory.EnumerateFiles(imgsDirectory).Count() + 1
        Dim imgPath = Path.Combine(imgsDirectory, $"IMG_{imgsCount}.jpg")

        Dim converter As New ImageConverter
        Using img As Image = CType(converter.ConvertFrom(pic), Image)
            img.Save(imgPath)
        End Using

        Dim cm As New ChatMessage With {.Author = name, .Picture = imgPath, .Time = DateTime.Now}
        Dim sender = _participants.Where(Function(u) String.Equals(u.Name, name)).FirstOrDefault
        ctxTaskFactory.StartNew(Sub() sender.Chatter.Add(cm)).Wait()

        If Not (SelectedParticipant IsNot Nothing AndAlso sender.Name.Equals_
                 (SelectedParticipant.Name)) Then
            ctxTaskFactory.StartNew(Sub() sender.HasSentNewMessage = True).Wait()
        End If
    End If
End Sub

Bringing a New Message into View

If there are many items in an ItemsControl, the ScrollViewer for the ItemsControl will not scroll a new item into view by default. I've written a Behavior which the ItemsControl is decorated with to enable a new message to scroll into view when the messages ItemsControl has many items.

public class BringNewItemIntoViewBehavior : Behavior<ItemsControl>
{
    private INotifyCollectionChanged notifier;

    protected override void OnAttached()
    {
        base.OnAttached();
        notifier = AssociatedObject.Items as INotifyCollectionChanged;
        notifier.CollectionChanged += ItemsControl_CollectionChanged;
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        notifier.CollectionChanged -= ItemsControl_CollectionChanged;
    }

    private void ItemsControl_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            var newIndex = e.NewStartingIndex;
            var newElement = AssociatedObject.ItemContainerGenerator.ContainerFromIndex(newIndex);
            var item = (FrameworkElement)newElement;
            item?.BringIntoView();
        }
    }
}
Public Class BringNewItemIntoViewBehavior
    Inherits Behavior(Of ItemsControl)

    Private notifier As INotifyCollectionChanged

    Protected Overrides Sub OnAttached()
        MyBase.OnAttached()
        notifier = CType(AssociatedObject.Items, INotifyCollectionChanged)
        AddHandler notifier.CollectionChanged, AddressOf ItemsControl_CollectionChanged
    End Sub

    Protected Overrides Sub OnDetaching()
        MyBase.OnDetaching()
        RemoveHandler notifier.CollectionChanged, AddressOf ItemsControl_CollectionChanged
    End Sub

    Private Sub ItemsControl_CollectionChanged(ByVal sender As Object, _
                  ByVal e As NotifyCollectionChangedEventArgs)
        If e.Action = NotifyCollectionChangedAction.Add Then
            Dim newIndex = e.NewStartingIndex
            Dim newElement = AssociatedObject.ItemContainerGenerator.ContainerFromIndex(newIndex)
            Dim item = CType(newElement, FrameworkElement)
            If item IsNot Nothing Then item.BringIntoView()
        End If
    End Sub
End Class

Switching Views

Switching from the login to the chat view is done depending on which UserMode is active. The XAML for this is in App.xaml/Application.xaml:

<DataTemplate x:Key="LoginTemplate">
    <views:LoginView/>
</DataTemplate>
<DataTemplate x:Key="ChatTemplate">
    <views:ChatView/>
</DataTemplate>

<Style x:Key="ChatContentStyle" TargetType="ContentControl">
    <Setter Property="ContentTemplate" Value="{StaticResource LoginTemplate}"/>
    <Style.Triggers>
        <DataTrigger Binding="{Binding UserMode}" Value="{x:Static enums:UserModes.Chat}">
            <Setter Property="ContentTemplate" Value="{StaticResource ChatTemplate}"/>
        </DataTrigger>
    </Style.Triggers>
</Style>

The views are loaded into a ContentControl in MainWindow.

<ContentControl Content="{Binding}" Style="{StaticResource ChatContentStyle}"/>

Conclusion

I have covered quite a bit of the code for this project here but, if you haven't done so, I recommend you download it and go through the rest of the code.

History

  • 11th April, 2017: Initial post
  • 15th April, 2017: Updated client to deal with server failure
  • 9th April, 2018: Updated code to enable sending of images. UI code also updated for aesthetics.
  • 17th April, 2018: Updated code to indicate when a connected client is typing.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here