Click here to Skip to main content
13,146,365 members (43,705 online)
Click here to Skip to main content
Add your own
alternative version

Stats

17.6K views
1.5K downloads
50 bookmarked
Posted 11 Apr 2017

SignalChat: WPF & SignalR Chat Application

, 18 Aug 2017
Rate this:
Please Sign up or sign in to vote.
A WPF instant messaging application using SignalR

Introduction

SignalChat is a WPF-MVVM instant messaging application that uses SignalR for real-time communication. You can check out this video showing the application in action.

Clone or download the project from GitHub: https://github.com/MeshackMusundi/SignalChat

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 as any new data that is available on the server can immeadiately be pushed out to all or some of the connected clients. With SignalR connected clients can also be made aware of the connection status of other clients.

SignalChat Server

The SignalChat server is hosted in a console application, a process referred to as self-hosting. The console application project contains classes that are used to configure and create a server hub.

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 is http://localhost:8080/signalchat and it's 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.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Collections.Concurrent;
using Microsoft.AspNet.SignalR;

namespace SignalServer
{
    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 BroadcastChat(string message)
        {
            var name = Clients.CallerState.UserName;
            if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(message))
            {
                Clients.Others.BroadcastMessage(name, message);
            }            
        }

        public void UnicastChat(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).UnicastMessage(sender, message);
            }
        }
    }
}
Imports System.Collections.Concurrent
Imports Microsoft.AspNet.SignalR

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    
End Class

ChatHub contains methods that connected clients can call when logging into/out of the chat server or when they want to send messages to other connected clients. ChatHub also contains functions that are used to handle client connection events. These event handlers are used to inform connected clients about a client's connection status e.g. in the case of the OnDisconnected event handler the function calls the client method ParticipantDisconnection().

SignalChat Client

ChatService

The ChatService class implements IChatService which defines functions and events that the client application will use when interacting with the SignalChat server.

public class ChatService : IChatService
{
    public event Action<string, string, MessageType> NewMessage;
    public event Action<string> ParticipantDisconnected;
    public event Action<User> ParticipantLoggedIn;
    public event Action<string> ParticipantLoggedOut;
    public event Action<string> ParticipantReconnected;
    public event Action ConnectionReconnecting;
    public event Action ConnectionReconnected;
    public event Action ConnectionClosed;

    private IHubProxy hubProxy;
    private HubConnection connection;
    private string url = "http://localhost:8080/signalchat";

    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>("BroadcastMessage", (n, m) => NewMessage?.Invoke(n, m, MessageType.Broadcast));
        hubProxy.On<string, string>("UnicastMessage", (n, m) => NewMessage?.Invoke(n, m, MessageType.Unicast));
        connection.Reconnecting += Reconnecting;
        connection.Reconnected += Reconnected;
        connection.Closed += Disconnected;

        ServicePointManager.DefaultConnectionLimit = 10;
        await connection.Start();
    }

    private void Disconnected()
    {
        ConnectionClosed?.Invoke();
    }

    private void Reconnected()
    {
        ConnectionReconnected?.Invoke();
    }

    private void Reconnecting()
    {
        ConnectionReconnecting?.Invoke();
    }

    public async Task<List<User>> LoginAsync(string name, byte[] photo)
    {
        return await hubProxy.Invoke<List<User>>("Login", new object[] { name, photo });
    }

    public async Task LogoutAsync()
    {
        await hubProxy.Invoke("Logout");
    }

    public async Task SendBroadcastMessageAsync(string msg)
    {
        await hubProxy.Invoke("BroadcastChat", msg);
    }

    public async Task SendUnicastMessageAsync(string recepient, string msg)
    {
        await hubProxy.Invoke("UnicastChat", new object[] { recepient, msg });
    }
}
Public Class ChatService
    Implements IChatService

    Public Event ParticipantDisconnected(name As String) Implements IChatService.ParticipantDisconnected
    Public Event ParticipantLoggedIn(participant As User) Implements IChatService.ParticipantLoggedIn
    Public Event ParticipantLoggedOut(name As String) Implements IChatService.ParticipantLoggedOut
    Public Event ParticipantReconnected(name As String) Implements IChatService.ParticipantReconnected
    Public Event ConnectionReconnecting() Implements IChatService.ConnectionReconnecting
    Public Event ConnectionReconnected() Implements IChatService.ConnectionReconnected
    Public Event ConnectionClosed() Implements IChatService.ConnectionClosed
    Public Event NewMessage(sender As String, msg As String, mt As MessageType) Implements IChatService.NewMessage

    Private hubProxy As IHubProxy
    Private connection As HubConnection
    Private url As String = "http://localhost:8080/signalchat"

    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)("BroadcastMessage",
                                       Sub(n, m) RaiseEvent NewMessage(n, m, MessageType.Broadcast))
        hubProxy.On(Of String, String)("UnicastMessage",
                                       Sub(n, m) RaiseEvent NewMessage(n, m, MessageType.Unicast))
        AddHandler connection.Reconnecting, AddressOf Reconnecting
        AddHandler connection.Reconnected, AddressOf Reconnected
        AddHandler connection.Closed, AddressOf Disconnected

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

    Private Sub Disconnected()
        RaiseEvent ConnectionClosed()
    End Sub

    Private Sub Reconnecting()
        RaiseEvent ConnectionReconnecting()
    End Sub

    Private Sub Reconnected()
        RaiseEvent ConnectionReconnected()
    End Sub

    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

    Public Async Function LogoutAsync() As Task Implements IChatService.LogoutAsync
        Await hubProxy.Invoke("Logout")
    End Function

    Public Async Function SendBroadcastMessageAsync(msg As String) As Task Implements IChatService.SendBroadcastMessageAsync
        Await hubProxy.Invoke("BroadcastChat", msg)
    End Function

    Public Async Function SendUnicastMessageAsync(recepient As String, msg As String) As Task Implements IChatService.SendUnicastMessageAsync
        Await hubProxy.Invoke("UnicastChat", New Object() {recepient, msg})
    End Function
End Class

The ConnectAsync() function creates a hub proxy that is used to define client methods that the server can call and also specifies handlers for some connection lifetime events. The other asynchronous functions can be used to call server methods.

Models

There are three model classes that are defined in the client project. User is a type of the parameter for the client method ParticipantLogin, basically a data transfer object.

public class User
{
    public string Name { get; set; }
    public string ID { get; set; }
    public byte[] Photo { get; set; }
}

public class ChatMessage
{
    public string Message { get; set; }
    public string Author { get; set; }
    public DateTime Time { get; set; }
    public bool IsOriginNative { get; set; }
}

public class Participant : ViewModelBase
{
    public string Name { get; set; }
    public byte[] Photo { get; set; }
    public ObservableCollection<ChatMessage> Chatter { get; set; }

    private bool _isLoggedIn = true;
    public bool IsLoggedIn
    {
        get { return _isLoggedIn; }
        set { _isLoggedIn = value; OnPropertyChanged(); }
    }

    private bool _hasSentNewMessage;
    public bool HasSentNewMessage
    {
        get { return _hasSentNewMessage; }
        set { _hasSentNewMessage = value; OnPropertyChanged(); }
    }

    public Participant() { Chatter = new ObservableCollection<ChatMessage>(); }
}
Public Class User
    Public Property Name As String
    Public Property ID As String
    Public Property Photo As Byte()
End Class

Public Class ChatMessage
    Public Property Message As String
    Public Property Author As String
    Public Property Time As DateTime
    Public Property IsOriginNative As Boolean
End Class

Public Class Participant
    Inherits ViewModelBase

    Public Property Name As String
    Public Property Photo As Byte()
    Public Property Chatter As New ObservableCollection(Of ChatMessage)

    Private _isLoggedIn As Boolean = True
    Public Property IsLoggedIn As Boolean
        Get
            Return _isLoggedIn
        End Get
        Set(value As Boolean)
            _isLoggedIn = value
            OnPropertyChanged()
        End Set
    End Property

    Private _hasSentNewMessage As Boolean
    Public Property HasSentNewMessage As Boolean
        Get
            Return _hasSentNewMessage
        End Get
        Set(value As Boolean)
            _hasSentNewMessage = value
            OnPropertyChanged()
        End Set
    End Property
End Class

The client project also contains two enums: MessageType and UserModes. The latter will be used to determine which view is displayed.

public enum MessageType
{
    Broadcast,
    Unicast
}

public enum UserModes
{
    Login,
    Chat
}
Public Enum MessageType
    Broadcast
    Unicast
End Enum

Public Enum UserModes
    Login
    Chat
End Enum

MainWindowViewModel

The client project contains only one view model, MainWindowViewModel, which contains commands that the view can use to connect to and log into or out of the chat server, and for sending messages to a specific client.

...

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 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>();
        byte[] pic = null;
        if (!string.IsNullOrEmpty(_photo)) pic = File.ReadAllBytes(_photo);
        users = await chatService.LoginAsync(_userName, pic);
        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 ICommand _sendMessageCommand;
public ICommand SendMessageCommand
{
    get
    {
        if (_sendMessageCommand == null) _sendMessageCommand =
                new RelayCommandAsync(() => SendMessage(), (o) => CanSendMessage());
        return _sendMessageCommand;
    }
}

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

private bool CanSendMessage()
{
    return !string.IsNullOrEmpty(Message) && _selectedParticipant != null && _selectedParticipant.IsLoggedIn;
}

...
...

Private _connectCommand As ICommand
Public ReadOnly Property ConnectCommand As ICommand
    Get
        If _connectCommand Is Nothing Then _connectCommand = New RelayCommandAsync(Function() Connect())
        Return _connectCommand
    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

Private _loginCommand As ICommand
Public ReadOnly Property LoginCommand As ICommand
    Get
        If _loginCommand Is Nothing Then _loginCommand =
                New RelayCommandAsync(Function() Login(), AddressOf CanLogin)
        Return _loginCommand
    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  
  
...

Private _sendMessageCommand As ICommand
Public ReadOnly Property SendMessageCommand As ICommand
    Get
        If _sendMessageCommand Is Nothing Then _sendMessageCommand =
                New RelayCommandAsync(Function() SendMessage(), AddressOf CanSendMessage)
        Return _sendMessageCommand
    End Get
End Property

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

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

...

In the client application messages can only be sent to one other connected client at a time. When logging in a user can select a photo that will be used as an avatar during a chat session. The photo should have a size of 150 x 150 or less.

private ICommand _selectPhotoCommand;
public ICommand SelectPhotoCommand
{
    get
    {
        if (_selectPhotoCommand == null) _selectPhotoCommand = new RelayCommand((o) => SelectPhoto());
        return _selectPhotoCommand;
    }
}

private void SelectPhoto()
{
    var pic = dialogService.OpenFile("Select image file", "Images (*.jpg;*.png)|*.jpg;*.png");
    if (!string.IsNullOrEmpty(pic))
    {
        var img = Image.FromFile(pic);
        if (img.Width > MAX_IMAGE_WIDTH || img.Height > MAX_IMAGE_HEIGHT)
        {
            dialogService.ShowNotification($"Image size should be {MAX_IMAGE_WIDTH} x {MAX_IMAGE_HEIGHT} or less.");
            return;
        }
        Photo = pic;
    }
}
Private _selectPhotoCommand As ICommand
Public ReadOnly Property SelectPhotoCommand As ICommand
    Get
        If _selectPhotoCommand Is Nothing Then _selectPhotoCommand = New RelayCommand(AddressOf SelectPhoto)
        Return _selectPhotoCommand
    End Get
End Property

Private Sub SelectPhoto()
    Dim pic = DialogService.OpenFile("Select image file", "Images (*.jpg;*.png)|*.jpg;*.png")
    If Not String.IsNullOrEmpty(pic) Then
        Dim img = Image.FromFile(pic)
        If img.Width > MAX_IMAGE_WIDTH OrElse img.Height > MAX_IMAGE_HEIGHT Then
            DialogService.ShowNotification($"Image size should be {MAX_IMAGE_WIDTH} x {MAX_IMAGE_HEIGHT} or less.")
            Exit Sub
        End If
        Photo = pic
    End If
End Sub

MainWindowViewModel also contains event handlers, for events raised by the IChatService implementing class object, that deal with new messages from other connected clients, their connection status, and server failure. In regards to server failure, the client app will attempt to log in again if the server goes down – if the server doesn't come back up before the expiration of the disconnect timeout period the client app will continously attempt to establish a new connection and log in again if the connection is successful.

private void NewMessage(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 void ParticipantLogin(User u)
{
    var ptp = Participants.FirstOrDefault(p => string.Equals(p.Name, u.Name));
    if (_isLoggedIn && ptp == null)
    {
        ctxTaskFactory.StartNew(() => Participants.Add(new Participant
        {
            Name = u.Name,
            Photo = u.Photo
        })).Wait();
    }
}

private void ParticipantDisconnection(string name)
{
    var person = Participants.Where((p) => string.Equals(p.Name, name)).FirstOrDefault();
    if (person != null) person.IsLoggedIn = false;
}

private void ParticipantReconnection(string name)
{
    var person = Participants.Where((p) => string.Equals(p.Name, name)).FirstOrDefault();
    if (person != null) person.IsLoggedIn = true;
}

private void Reconnecting()
{
    IsConnected = false;
    IsLoggedIn = false;
}

private async void Reconnected()
{
    var pic = Avatar();
    if (!string.IsNullOrEmpty(_userName)) await chatService.LoginAsync(_userName, pic);
    IsConnected = true;
    IsLoggedIn = true;
}

private async void Disconnected()
{
    var connectionTask = chatService.ConnectAsync();
    await connectionTask.ContinueWith(t => {
        if (!t.IsFaulted)
        {
            IsConnected = true;
            chatService.LoginAsync(_userName, Avatar()).Wait();
            IsLoggedIn = true;
        }
    });
}
Private Sub ParticipantLogin(ByVal u As User)
    Dim ptp = Participants.FirstOrDefault(Function(p) String.Equals(p.Name, u.Name))
    If _isLoggedIn AndAlso ptp Is Nothing Then
        ctxTaskFactory.StartNew(Sub() Participants.Add(New Participant With {.Name = u.Name, .Photo = u.Photo})).Wait()
    End If
End Sub

Private Sub ParticipantDisconnection(ByVal name As String)
    Dim person = Participants.Where(Function(p) String.Equals(p.Name, name)).FirstOrDefault
    If person IsNot Nothing Then person.IsLoggedIn = False
End Sub

Private Sub ParticipantReconnection(ByVal name As String)
    Dim person = Participants.Where(Function(p) String.Equals(p.Name, name)).FirstOrDefault
    If person IsNot Nothing Then person.IsLoggedIn = True
End Sub

Private Sub Reconnecting()
    IsConnected = False
    IsLoggedIn = False
End Sub

Private Async Sub Reconnected()
    Dim pic = Avatar()
    If Not String.IsNullOrEmpty(_userName) Then Await ChatService.LoginAsync(_userName, pic)
    IsConnected = True
    IsLoggedIn = True
End Sub

Private Async Sub Disconnected()
    Dim connectionTask = ChatService.ConnectAsync()
    Await connectionTask.ContinueWith(Sub(t)
                                          If Not t.IsFaulted Then
                                              IsConnected = True
                                              ChatService.LoginAsync(_userName, Avatar()).Wait()
                                              IsLoggedIn = True
                                          End If
                                      End Sub)
End Sub

RelayCommandAsync

RelayCommandAsync in an asynchronous friendly implementation of the ICommand interface and objects of its type are used several times in MainWindowViewModel.

public class RelayCommandAsync : ICommand
{
    private readonly Func<Task> _execute;
    private readonly Predicate<object> _canExecute;
    private bool isExecuting;

    public RelayCommandAsync(Func<Task> execute) : this(execute, null) { }

    public RelayCommandAsync(Func<Task> execute, Predicate<object> canExecute)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter)
    {
        if (!isExecuting && _canExecute == null) return true;
        return (!isExecuting && _canExecute(parameter));
    }

    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }

    public async void Execute(object parameter)
    {
        isExecuting = true;
        try { await _execute(); }
        finally { isExecuting = false; }
    }
}
Public Class RelayCommandAsync
    Implements ICommand

    Private ReadOnly _execute As Func(Of Task)
    Private ReadOnly _canExecute As Predicate(Of Object)
    Private isExecuting As Boolean

    Public Sub New(execute As Func(Of Task))
        Me.New(execute, Nothing)
    End Sub

    Public Sub New(execute As Func(Of Task), canExecute As Predicate(Of Object))
        _execute = execute
        _canExecute = canExecute
    End Sub

    Public Function CanExecute(parameter As Object) As Boolean Implements ICommand.CanExecute
        If Not isExecuting AndAlso _canExecute Is Nothing Then Return True
        Return Not isExecuting AndAlso _canExecute(parameter)
    End Function

    Public Custom Event CanExecuteChanged As EventHandler Implements ICommand.CanExecuteChanged
        AddHandler(value As EventHandler)
            AddHandler CommandManager.RequerySuggested, value
        End AddHandler

        RemoveHandler(value As EventHandler)
            RemoveHandler CommandManager.RequerySuggested, value
        End RemoveHandler

        RaiseEvent(sender As Object, e As EventArgs)
        End RaiseEvent
    End Event

    Public Async Sub Execute(parameter As Object) Implements ICommand.Execute
        isExecuting = True
        Try
            Await _execute()
        Finally
            isExecuting = False
        End Try
    End Sub
End Class

IoC

The client application uses Unity as its IoC container. The project contains a utility, ViewModelLocator, which instantiates the Unity container and contains a read-only property that returns an instance of MainWindowViewModel.

public class ViewModelLocator
{
    private UnityContainer container;

    public ViewModelLocator()
    {
        container = new UnityContainer();
        container.RegisterType<IChatService, ChatService>();
        container.RegisterType<IDialogService, DialogService>();
    }

    public MainWindowViewModel MainVM
    {
        get { return container.Resolve<MainWindowViewModel>(); }
    }
}
Public Class ViewModelLocator
    Private container As UnityContainer

    Public Sub New()
        container = New UnityContainer
        container.RegisterType(Of IChatService, ChatService)()
        container.RegisterType(Of IDialogService, DialogService)()
    End Sub

    Public ReadOnly Property MainVM As MainWindowViewModel
        Get
            Return container.Resolve(Of MainWindowViewModel)()
        End Get
    End Property
End Class

LoginView

The LoginView is the first view the user sees when the client application is launched. The following is the xaml for the user control,

<UserControl x:Class="SignalChat.Views.LoginView"

             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 

             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"

             xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"

             xmlns:local="clr-namespace:SignalChat.Views"

             mc:Ignorable="d"              

             d:DesignHeight="400" d:DesignWidth="600">
    <Grid FocusManager.FocusedElement="{Binding ElementName=UsernameTxtBox}">
        <materialDesign:Card VerticalAlignment="Center" HorizontalAlignment="Center" Width="200" Height="280">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="160"/>
                    <RowDefinition/>
                </Grid.RowDefinitions>
                <materialDesign:ColorZone Mode="Light" Margin="0" VerticalAlignment="Stretch" Background="WhiteSmoke">
                    <Grid HorizontalAlignment="Center" Width="120" Height="120" VerticalAlignment="Center"

                          SnapsToDevicePixels="True">
                        <Grid.OpacityMask>
                            <VisualBrush Visual="{Binding ElementName=ClipEllipse}"/>
                        </Grid.OpacityMask>
                        <Ellipse x:Name="ClipEllipse" Fill="White" Stroke="Black"/>
                        <materialDesign:PackIcon Kind="AccountCircle" Width="144" Height="144" Margin="-12"/>
                        <Image Source="{Binding Photo, FallbackValue={StaticResource BlankImage},
                               TargetNullValue={StaticResource BlankImage}}"/>
                        <Ellipse Stroke="Black" StrokeThickness="1" UseLayoutRounding="True" Opacity="0.2"/>
                    </Grid>
                </materialDesign:ColorZone>
                <Button Grid.Row="0" Style="{StaticResource MaterialDesignFloatingActionMiniAccentButton}" 

                        HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,36,16"

                        Command="{Binding SelectPhotoCommand}"

                        ToolTip="Click to select picture">
                    <materialDesign:PackIcon Kind="FileImage" Width="20" Height="20" />
                </Button>
                <Border Grid.Row="1" BorderBrush="{DynamicResource MaterialDesignDivider}" BorderThickness="0,1,0,0">
                    <StackPanel Grid.Row="1" Orientation="Vertical" HorizontalAlignment="Center"

                                VerticalAlignment="Top" Margin="0,10,0,0">
                        <TextBox x:Name="UsernameTxtBox" Width="150" Margin="0,5"

                                 materialDesign:HintAssist.Hint="Username"   

                                 Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}">
                            <TextBox.InputBindings>
                                <KeyBinding Command="{Binding LoginCommand}" Key="Return"/>
                            </TextBox.InputBindings>
                        </TextBox>
                        <Button Content="Login" Margin="0,10,0,0" Command="{Binding LoginCommand}"/>
                    </StackPanel>
                </Border>
            </Grid>
        </materialDesign:Card>
    </Grid>
</UserControl>

The client project references the Material Design in XAML Toolkit and some of the controls from the library are used in LoginView. The toolkit also contains a wide collection of icons, some of which are used in the project.

ChatView

The ChatView is loaded when the user is able to successfully log into the chat server. It consists primarily of a ListBox, which displays a list of other connected clients; an ItemsControl for showing messages/chatter between the user and a particular client; a TextBox for typing a message; and a Button for sending the typed message. The send button is only active if a connected client is selected and there's text in the message TextBox.

...
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="220"/>
        <ColumnDefinition/>
    </Grid.ColumnDefinitions>

    <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>

    <!-- Messages -->
    <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>

    <Border Grid.Row="1" Grid.Column="1" SnapsToDevicePixels="True"

            BorderBrush="{DynamicResource MaterialDesignDivider}">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition/>
                <ColumnDefinition Width="50"/>
            </Grid.ColumnDefinitions>
            <!-- Message -->
            <TextBox x:Name="MessageTxtBox" Margin="10,10,0,10" MaxHeight="80"                                         

                     BorderThickness="1" BorderBrush="{DynamicResource MaterialDesignDivider}"

                     TextWrapping="Wrap" AcceptsReturn="True" CaretBrush="#7F000000" 

                     materialDesign:TextFieldAssist.DecorationVisibility="Hidden"

                     VerticalScrollBarVisibility="Auto"

                     Text="{Binding Message, UpdateSourceTrigger=PropertyChanged}"/>
            <!-- Send -->
            <Button x:Name="SendButton" Grid.Column="1" Margin="10"

                    Style="{DynamicResource SendButtonStyle}"

                    Command="{Binding SendMessageCommand}"/>
        </Grid>
    </Border>
</Grid>
...

A ListBoxItem displays a photo, name, and ellipse whose color indicates client and server connection status – the ellipse is green if a client is connected and logged into the chat server and there is no server failure, and red if a client has either disconnected or logged out or there is server failure. The ListBoxItem also displays a message icon if a client has sent a message which hasn't been viewed by the user. Here's the xaml for the ListBox's data template.

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

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

            Height="50">
        <Grid Margin="5,0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="50"/>
                <ColumnDefinition/>
                <ColumnDefinition Width="15"/>
                <ColumnDefinition Width="25"/>
            </Grid.ColumnDefinitions>
            <!-- Pic -->
            <Grid 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="1" VerticalAlignment="Center" HorizontalAlignment="Left"

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

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

            <materialDesign:PackIcon Name="NewMessageIcon" Grid.Column="2" SnapsToDevicePixels="True"

                                     VerticalAlignment="Center" HorizontalAlignment="Center"

                                     Kind="MessageReplyText" Opacity="0.5" Visibility="Hidden"/>
            <!-- Online -->
            <Ellipse Grid.Column="3" 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>
    </DataTemplate.Triggers>
</DataTemplate>

In the messages ItemsControl messages posted by the user are displayed in a blue Border which is 'right' aligned. The following is the xaml for the ItemsControl data template,

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

               Margin="10,0,60,10" BorderBrush="#BCAAA4" CornerRadius="2" SnapsToDevicePixels="True">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition Height="15"/>
            </Grid.RowDefinitions>
            <TextBlock VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Margin="7,5,7,0"                                

                               TextWrapping="Wrap" Text="{Binding Message}"/>
            <TextBlock Grid.Row="1" 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="Margin" Value="60,0,10,10"/>
            <Setter TargetName="MessageBorder" Property="Background" Value="#BBDEFB"/>
            <Setter TargetName="MessageBorder" Property="BorderBrush" Value="#64B5F6"/>
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>

Bringing a New ItemsControl Item 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. To enable this I've written a Behavior which the ItemsControl is decorated with,

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

View switching is done depending on which UserMode is active. The following is the xaml for this 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}"/>

Connection & Logout

Connection with the chat server is done when the application window is loaded while logout is done when the window is closing. The commands for connection and logout are called using InvokeCommandActions.

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

Conclusion

I've covered quite a bit of the code for this project but, if you haven't done so, I would recommend you download the solution from the download links at the top of the article page and go through the rest of the code.

History

  • 11th April, 2017: Initial post,
  • 15th April, 2017: Updated client to deal with server failure

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Meshack Musundi
Software Developer
Kenya Kenya
Meshack is a software developer with a passion for WPF.

Awards,

  • CodeProject MVP 2013

  • CodeProject MVP 2012


You may also be interested in...

Pro
Pro

Comments and Discussions

 
Questionhow to implement chat bubble in chat app Pin
vinayakypanchal@gmail.com21-Sep-17 1:11
membervinayakypanchal@gmail.com21-Sep-17 1:11 
Questionhow to communicate this chat with SignalR web? is it possible with this chat application?if it is possible then what should be the code for receive message? Pin
vinayakypanchal@gmail.com28-Aug-17 2:13
membervinayakypanchal@gmail.com28-Aug-17 2:13 
AnswerRe: how to communicate this chat with SignalR web? is it possible with this chat application?if it is possible then what should be the code for receive message? Pin
Meshack Musundi29-Aug-17 2:15
professionalMeshack Musundi29-Aug-17 2:15 
QuestionWhere is Recieve method? Pin
vinayakypanchal@gmail.com22-Aug-17 2:10
membervinayakypanchal@gmail.com22-Aug-17 2:10 
AnswerRe: Where is Recieve method? Pin
Meshack Musundi23-Aug-17 3:37
professionalMeshack Musundi23-Aug-17 3:37 
QuestionNot Working With IP Pin
mahssali23-Jul-17 23:40
membermahssali23-Jul-17 23:40 
AnswerRe: Not Working With IP Pin
Meshack Musundi24-Jul-17 7:02
professionalMeshack Musundi24-Jul-17 7:02 
GeneralRe: Not Working With IP Pin
mahssali24-Jul-17 19:11
membermahssali24-Jul-17 19:11 
GeneralRe: Not Working With IP Pin
Meshack Musundi24-Jul-17 22:13
professionalMeshack Musundi24-Jul-17 22:13 
QuestionAnother Question Pin
Kevin Marois5-Jul-17 12:19
professionalKevin Marois5-Jul-17 12:19 
AnswerRe: Another Question Pin
Meshack Musundi7-Jul-17 2:45
professionalMeshack Musundi7-Jul-17 2:45 
QuestionBroadcast Pin
paal solvberg13-Jun-17 0:59
memberpaal solvberg13-Jun-17 0:59 
AnswerRe: Broadcast Pin
Meshack Musundi14-Jun-17 23:03
professionalMeshack Musundi14-Jun-17 23:03 
PraiseRe: Broadcast Pin
paal solvberg16-Jun-17 0:38
memberpaal solvberg16-Jun-17 0:38 
QuestionNice chat application Pin
Member 132058967-Jun-17 22:35
memberMember 132058967-Jun-17 22:35 
GeneralRe: Nice chat application Pin
Meshack Musundi12-Jun-17 21:33
professionalMeshack Musundi12-Jun-17 21:33 
GeneralRe: Nice chat application Pin
Mou_kol12-Jun-17 22:26
memberMou_kol12-Jun-17 22:26 
AnswerRe: Nice chat application Pin
Meshack Musundi12-Jun-17 22:42
professionalMeshack Musundi12-Jun-17 22:42 
BugProblem with Disconnected Users Pin
Member 1038187619-May-17 8:33
memberMember 1038187619-May-17 8:33 
GeneralRe: Problem with Disconnected Users Pin
Meshack Musundi22-May-17 7:24
professionalMeshack Musundi22-May-17 7:24 
QuestionSignalR Chat MVC 5 Example Pin
Member 1317690814-May-17 8:08
memberMember 1317690814-May-17 8:08 
QuestionIs it peer to peer chat Pin
Tridip Bhattacharjee10-May-17 22:49
professionalTridip Bhattacharjee10-May-17 22:49 
QuestionSuggestion for UI Pin
Tridip Bhattacharjee9-May-17 23:16
professionalTridip Bhattacharjee9-May-17 23:16 
AnswerRe: Suggestion for UI Pin
Meshack Musundi10-May-17 0:04
professionalMeshack Musundi10-May-17 0:04 
GeneralRe: Suggestion for UI Pin
Tridip Bhattacharjee10-May-17 0:10
professionalTridip Bhattacharjee10-May-17 0:10 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.170915.1 | Last Updated 18 Aug 2017
Article Copyright 2017 by Meshack Musundi
Everything else Copyright © CodeProject, 1999-2017
Layout: fixed | fluid