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.
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"/>
<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>
<TextBlock Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Left"
Margin="5,0" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Text="{Binding Name}" SnapsToDevicePixels="True"/>
<materialDesign:PackIcon Name="NewMessageIcon" Grid.Column="3" SnapsToDevicePixels="True"
VerticalAlignment="Center" HorizontalAlignment="Center"
Kind="MessageReplyText" Opacity="0.8" Visibility="Hidden"/>
<materialDesign:PackIcon Name="TypingIcon" Grid.Column="3" SnapsToDevicePixels="True"
VerticalAlignment="Center" HorizontalAlignment="Center"
Kind="Feather" Opacity="0.8" Visibility="Hidden"/>
<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.