Click here to Skip to main content
14,577,799 members

The Code Project API - Part 2 - Getting Some REST

Rate this:
4.97 (23 votes)
Please Sign up or sign in to vote.
4.97 (23 votes)
6 May 2015CPOL
In this article, we're going to extend the work done in Part 1 and start reading in from the REST API.



Welcome to this, the second article in a series in which we build up a wrapper for the CodeProject API. In the first article, we started off developing a Portable Class Library implementation that would allow us to develop applications that use the API that run on a variety of platforms, without having to worry about the actual implementation details of the API. To that end, we developed an implementation that retrieves an authorization token from CodeProject. This token is going to be used in conjunction with the other API calls that we are going to make, so it seemed to be a very good place to start.

In the first article, we split out the interface definition from the actual API implementation. This may have seemed like a strange choice at the time but it will allow us to provide a reference data implementation that we can use to design our UIs and test out code without actually having to call the site. We also saw the beginnings of my objectives for the API. Let's restate them if they are still valid, and add new ones that we want to adhere to.

Objective 1

The wrapper has to be Portable Class Library compliant. As long as I adhere to strict PCL, I should be able to produce a wrapper that can be used in lots of environments. The first cut of the wrapper targets .NET 4.5, Windows 8, Windows Phone 8 and Windows Phone Silverlight 8. I will later be targeting Xamarin for this wrapper, so retargeting will take place that make it easy for us to use on Android and iOS as well.

Objective 2

The API has to follow what I consider to be good design principles as much as possible. Now, this is a highly subjective thing but, where possible, I will be aiming to use SOLID principles extensively. While the development will largely follow TDD, there will be times that I stray away from this in order to use some feature that is not easily testable, such as the HttpClient. If I follow Single Responsibility as much as possible, I will be minimising the "touchpoints" so to speak. Ultimately, I am going to be producing something for others to use so I have to be as pragmatic as possible – this means that there will be some design compromises but, I will note them down here.

Objective 3

This will be an iterative build. I am limiting the features that I produce for the first version to simply getting an access token - the token will be vital later on when we're using the API. This may seem to be a trivial thing but as there are a large number of design decisions that are going to go into the initial design, this is a good basis for getting something "out of the door" as a proving ground as a first pass. I would caution against relying too heavily on this version because it's highly likely that I will be refactoring things quite heavily in future builds but this is a good starting point.

Objective 4

I want to use IoC, but I don't want to force you to have to use my chosen IoC. Okay, that's a strange statement but as I would like to get this adopted by as many people as possible, I am designing it so that you can use whatever mechanism suits you. Ultimately, if you want to "new" everything up yourself, that's entirely your choice but I like IoC. Saying that, I don't expect that you would have to register everything yourself if you want to go IoC. The code provides an abstraction that we can use to hide the fact that we are using IoC, letting us drop in the IoC container we prefer. I'll be providing an Autofac implementation, and this will demonstrate how we can wrap things up in an appropriate IoC container.

Objective 5

Unless I have a very good reason, the code is going to follow the Single Responsibility Principle as much as possible. This means that there are going to be lots of small classes that hook things together. I'm a big fan of SRP.

Objective 6

I'm not going to write things I don't need to write. This means that I'm going to be using packages that other people have written for things like converting JSON unless I find that I need something that doesn't exists in PCL form.

Objective 7

Apart from sample projects and tests, all the libraries will be Portable Class Libraries. This just goes back to the fact I want this to be available as widely as possible.

Objective 8

Inputs aren't to be trusted. If a method or constructor accepts a type, at the very least, we should test to make sure it's set. There will be some validity checking introduced but the first stage is to make sure that every public constructor or method doesn't trust its inputs. This is easily verifiable through tests.

Objective 9

Initially, I will only be providing unit-level tests. I'm not going to be performing System-Under-Test (SUT) tests so inputs will be mocked as much as possible.

Objective 10

At this stage, I'm not worried too much about exception handling or logging. The code, for this iteration, is going to naively assume that everything is okay when it calls out to external sources. In the next iteration, I'll be looking to put more formal handling in to cater for the so-called "unhappy day" cases. This means that there will be no custom exceptions being raised at this stage – these will be introduced in later stages.

Objective 11

Models will be backed by interfaces. While not strictly necessary for the models, I like to work from interfaces so I will be using interfaces here. This does impose certain design constraints that we will see later on when we're going to be decoding the results, but I do like the fact that the interface is a hard-and-fast contract.

Objective 12

It's okay to refactor. I would expect, in this phase, that I am going to have to start refactoring some of the previous codebase. That's fine, after all, our unit tests should still pass if we do this properly.

So, there you have it. My twelve objectives for this iteration. Let's take a look at the code.

Revisiting HttpClientBulder

The version of HttpClientBuilder that I created for retrieving the access token works fine for that particular case, but other APIs need to add the access token to the default request headers. The other settings will be the same as the HttpClientBuilder, so it looks like I need to create a specialist version of ClientRequest that accepts an instance of IAccessToken to add the missing header in. The first instinct that you might have is to try and derive our specialised class from HttpClientBuilder. While it's true that changing the HttpClient field from private to protected would allow us to add extra detail in the derived class constructor, we have one problem; we have to pass in an instance of IAccessToken which exposes the access token retrieval as an async method. This means that we would have to be able to await the result of the retrieval of the access token, and you cannot use async/await on a constructor (this would mean that your constructor would have to be able to return a Task which is, of course, not allowed).

We are left with two choices here now. We either have to duplicate the code in a new class, or we create a base class that contains the common code and have HttpClientBuilder and our new class derive from this class. Unfortunately, we are going to lose the readonly protection on our instance of HttpClient but that is something we cannot help here. At this point, we would probably think that dropping the readonly aspect would mean that we could miss out a step and just derive from HttpClientBuilder here - adding in the missing bit. Remember that I said that we need to retrieve the access token using an async call, this means that our GetToken call needs to be asynchronous. This, of course, means that we would end up exposing the ordinary GetClient and our new asynchronous GetClientAsync method - violating SRP. That's why we're going to create a common base class and expose just the bit we need.

Note that there are ways around this constructor initialization issue but they would hit the problem that we cannot get the access token until we have the populated user details. This would prevent us from being able to properly build up the object graph.

The first thing I'm going to do is create an abstract HttpClientBuilderBase class. Rather, the first thing I'm going to do is build an HttpClientBuilderBaseTest class. Just because we're moving code around, it doesn't mean that we can ignore the tests. I'm not going to go through each test - you can read through them to see how I build up the HttpClientBuilderBase class from the tests. Ultimately, we end up with this as our actual class implementation.

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using CodeProject.Api.Interfaces.Settings;

namespace CodeProject.Api.Http
  public abstract class HttpClientBuilderBase
    protected HttpClient Client;

    protected HttpClientBuilderBase(HttpClient client, IClientSettings clientSettings)
      if (client == null) throw new ArgumentNullException("client");
      if (clientSettings == null) throw new ArgumentNullException("clientSettings");

      client.BaseAddress = new Uri(clientSettings.BaseUrl);
      client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

      Client = client;

Now all we need to do is change HttpClientBuilder so that it derives from this class. If we run our tests for HttpClientBuilder, they should all still pass.

using System.Net.Http;
using CodeProject.Api.Interfaces.Http;
using CodeProject.Api.Interfaces.Settings;

namespace CodeProject.Api.Http
  public class HttpClientBuilder : HttpClientBuilderBase, IHttpClientBuilder
    public HttpClientBuilder(HttpClient client, IClientSettings clientSettings)
      : base(client, clientSettings)

    public HttpClient GetClient()
      return Client;

That's the beauty of having unit tests in place. They give you a reasonably good idea that your implementation still works when you refactor.


As we want a version of our HttpClient construction to come with the authorization set to include the access token as the bearer, we want this class to accept IAccessToken. The beauty about doing this is we are going to ensure that the access token will be allocated before we can actually do any processing with this class - even if we don't explicitly call this anywhere in our application. Now we want to create our new class. We'll still follow the principle of write a unit test and add the code. When we finish, we end up with an implementation that we can use with the access token.

using System;
using System.Net.Http;
using System.Threading.Tasks;
using CodeProject.Api.Interfaces.Settings;
using CodeProject.Api.Interfaces.Token;
using CodeProject.Api.Interfaces.Http;

namespace CodeProject.Api.Http
  public class HttpClientWithAccessTokenBuilder : HttpClientBuilderBase, IHttpClientWithAccessTokenBuilder
    private readonly IAccessToken accessToken;

    public HttpClientWithAccessTokenBuilder(HttpClient client, IClientSettings clientSettings, IAccessToken accessToken)
      : base(client, clientSettings)
      if (accessToken == null) throw new ArgumentNullException("accessToken");
      this.accessToken = accessToken;

    public async Task<HttpClient> GetClientAsync()
      string token = await accessToken.GetTokenAsync();
      Client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);

      return Client;

With each class that we build up, we're confining ourselves to providing small, easy to test code. The only thing that we need to add is the new request header. This is easy to test and completely unambiguous. What could be better than that?

Those who have read through the code from my first article will realise that I am going to register this in ModuleRegistration. Right now, I'm just going to register this as single instance. If we make this multiple instance right now, this would have an implication on the way that the access token handling works because there could be race conditions accessing the token. At this stage, that's not a complication that we need to worry about, so we will leave this as single instance.


Right, that's our new HttpClient infastructure in place, let's set about actually using it. It's time to get a little bit vain and look at how we can use the API to get our reputation. With the actual API calls, I'm going to adopt the convention that the classes making the API call will reside inside the namespace that the API is identified in, in the CodeProject API documentation. As Reputation is defined in the My area, this is going to be the namespace we use. As I discussed in my first article, I have taken the approach that I want to separate the retrieval of the request out from the actual request model itself, so the first class that is going to be defined is FetchReputation.

using System.Net.Http;
using System.Threading.Tasks;
using CodeProject.Api.Interfaces.Http;
using CodeProject.Api.Interfaces.My;
using Newtonsort.Json;

namespace CodeProject.Api.My
  public class FetchReputation :IFetchReputation
    private const string Url = "/v1/My/Reputation";
    private readonly IHttpClientWithAccessTokenBuilder accessTokenBuilder;

    public FetchReputation(IHttpClientWithAccessTokenBuilder accessTokenBuilder)
      this.accessTokenBuilder = accessTokenBuilder;

    public async Task<string> GetReputationAsync()
      HttpClient client = await accessTokenBuilder.GetClientAsync();
      HttpResponseMessage responseMessage = await client.GetAsync(Url);
      string jsonMessage = await responseMessage.Content.ReadAsStringAsync();

      return jsonMessage;

As we can see here, this is an incredibly trivial piece of code but, because of the way that we have built our infrastructure so far, it's actually doing a lot. Now, I know that the other API calls are going to follow a similar pattern to this but, right now, I don't need to implement this for those APIs; so, following YAGNI, I'm not going to worry about moving parts into base classes. Only do as much as we need to do and no more, as we can always revisit this when we need to.


Since we are pulling data to display, we really don't want to be dealing with raw JSON. We want to build a model that encapsulates what the JSON is representing. The JSON that we are going to receive from the call to Reputation looks something like this:


This gives us the idea that our top level model will contain an integer value for the total number of points a user has, an enumeration containing the breakdown of the reputation types and a URL to the users reputation graph. From this, we decide that our model probably wants to look something like this:

public class ReputationModel : IReputationModel
  public int TotalPoints { get; set; }
  public IEnumerable<IReputationType> ReputationTypes { get; set; }
  public string GraphUrl { get; set; }


Obviously, we also need to define what the reputation type is going to look like. Looking at the JSON, we see that there's a reputation type name, the number of points earned at that type, a level and the user designation at that level. We deciide that we want our model to look like this:

public class ReputationType : IReputationType
  public string Name { get; set; }
  public int Points { get; set; }
  public string Level { get; set; }
  public string Designation { get; set; }

There, we have a model of our data. Now all we need to do is hook into FetchReputation to retrieve our data and convert it from JSON into our model.


The glue that binds the retrieval mechanism to the model is the Reputation class. Before I explain what it does, it's worth taking a look at the class itself:

public class Reputation : IReputation
  private readonly IFetchReputation fetchReputation;
  private readonly IReputationToConverterMapping mapping;
  private readonly IJsonConverter converter;

  public Reputation(IFetchReputation fetchReputation, IReputationToConverterMapping mapping,
    IJsonConverter converter)
    if (fetchReputation == null) throw new ArgumentNullException("fetchReputation");
    if (mapping == null) throw new ArgumentNullException("mapping");
    if (converter == null) throw new ArgumentNullException("converter");

    this.fetchReputation = fetchReputation;
    this.mapping = mapping;
    this.converter = converter;

  public async Task<IReputationModel> GetReputationAsync()
    string reputation = await fetchReputation.GetReputationAsync();
    return converter.Deserialize<IReputationModel>(reputation, mapping.GetReputationMapping());

We'll start with the easy bit - the call to GetReputationAsync. Obviously, this is calling the method in FetchReputation that goes out to the API and retrieves the JSON string that we are going to convert into the model. That's what we're going to be returning out of our class here - the reputation model. The reputation model, however, is being defined and returned here using the interfaces rather than the concrete class. If we attempt to decode JSON into an interface using Json.NET, you'll soon encounter a limitation - namely that Json.NET doesn't support converting to interfaces directly.


Fortunately for us, Json.NET allows us to insert custom converters into the conversion pipeline. To provide the support we need, we create a converter that will use an interface to type mapping that we provide to deserialize into an appropriate concrete type whenever the model type uses an interface. What's particularly clever about this is the fact that we only need to take care of the individual interface mappings; as the deserialization process occurs on a line by line basis, the converter will map the enumerable elements correctly - we don't need to tell it how to populate an IEnumerable.

public class JsonModelConverter : Newtonsoft.Json.JsonConverter, IJsonModelConverter
  private readonly Dictionary<Type, Type> converters = new Dictionary<Type, Type>();

  public void MapInterfaceToType(Type typeName, Type type)
    if (typeName == null) throw new ArgumentNullException("typeName");
    if (type == null) throw new ArgumentNullException("type");

    converters.Add(typeName, type);

  public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)

  public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
    JsonSerializer serializer)
    Type type;
    if (converters.TryGetValue(objectType, out type))
      return serializer.Deserialize(reader, type);
    throw new JsonSerializationException(string.Format("Type {0} unexpected.", objectType.FullName));

  public override bool CanConvert(Type objectType)
    return converters.ContainsKey(objectType);

As we are only worrying about deserializing JSON, we don't have to worry about the code for WriteJson.

When we use this class, we need to tell it what interfaces map to what concrete types. We use MapInterfaceToType to, cunningly enough, map an interface to the relevant concrete type. By doing this, when the converter runs, calls to CanConvert simply check to see whether or not the converter contains that key and ReadJson gets the appropriate concrete type from the Dictionary, if registered, to Deserialize into. That's how simple the converter is to use.


This class is where we register the relevant interface to the appropriate concrete type. Now, you might wonder why we don't use some form of IoC resolver here to automatically perform this mapping. We don't do this for a couple of reasons:

  1. We aren't going to map our model interfaces to the IoC container. The only way we should be seeing these types is through the model deserialization process.
  2. If we were to automatically resolve everything that our IoC container contained, we would be registering a lot of types that the converter doesn't need to know about.
public class ReputationToConverterMapping : IReputationToConverterMapping
  private readonly IJsonSettings settings;
  public ReputationToConverterMapping(IJsonSettings settings)
    if (settings == null) throw new ArgumentNullException();

    settings.ModelConverter.MapInterfaceToType(typeof(IReputationModel), typeof(ReputationModel));
    settings.ModelConverter.MapInterfaceToType(typeof(IReputationType), typeof(ReputationType));

    this.settings = settings;

  public IJsonSettings GetReputationMapping()
    return settings;


We saw a couple of calls to ModelConverter in the above code. This is provided through the JsonSettings class. This class conveniently lets us map our custom JsonModelConverter to the JsonSerializerSettings as a converter. We'll use these settings when we attempt to Deserialize the models.

public class JsonSettings : IJsonSettings
  private readonly JsonSerializerSettings serializerSettings;
  private readonly IJsonModelConverter modelConverter;

  public JsonSettings(IJsonModelConverter modelConverter)
    if (modelConverter == null) throw new ArgumentNullException("modelConverter");

    serializerSettings = new JsonSerializerSettings {TypeNameHandling = TypeNameHandling.None};
    Newtonsoft.Json.JsonConverter converter = (Newtonsoft.Json.JsonConverter)modelConverter;
    this.modelConverter = modelConverter;

  public IJsonModelConverter ModelConverter { get { return modelConverter; } }

  public JsonSerializerSettings GetSerializerSettings()
    return serializerSettings;


The final pieces of the puzzle is the call to the Deserialize method. You may remember, from the first article, that we had a JsonConverter class in place that allowed us to get a single value from a token. Obviously, that was never going to be enough when it came to more complex models, so we need to extend the converter to support deserializing more complex types. That's where the Deserialize method comes in. This method is responsible for deserializing the model using the appropriate serialization settings.

public T Deserialize<T>(string model, IJsonSettings settings)
  if (string.IsNullOrWhiteSpace(model)) throw new ArgumentException("You must specify the model.");
  if (settings == null) throw new ArgumentNullException();

  return JsonConvert.DeserializeObject<T>(model, settings.GetSerializerSettings());

We're going to leave the original single token conversion in place for the moment as it serves our needs right now. Ultimately, however, we are going to create a model that encapsulates the access token, and we will be able to remove support for the single token at that point as we will deserialize directly into the model. For the moment, however, we aren't going to take this step.

And that's it. That's how simple our code has to be to handle deserializing the reputation API. Now, all we need to do is update our example application to call our reputation API.


In our console application, I've created this class to handle the calls to the reputation API.

public class ReputationSample : SampleBase, IReputationSample
  private readonly IReputation fetchReputation;
  public ReputationSample(IReputation fetchReputation, IUserDetails userDetails)
    : base(userDetails)
    if (fetchReputation == null) throw new ArgumentNullException("fetchReputation");
    this.fetchReputation = fetchReputation;
  public async Task GetReputationAsync()
    var token = await fetchReputation.GetReputationAsync();
    Console.WriteLine("Total points: {0}", token.TotalPoints);
    foreach (var repType in token.ReputationTypes)
      Console.WriteLine("{0} - {1} - {2}", repType.Name, repType.Points, repType.Designation);

SampleBase is a simple refactoring from the older codebase to encapsulate the call to EnterCredentials. If you look further into the original sample, you'll see that the console application no longer calls the access token API directly (even though we haven't actually removed this part of the codebase from the sample). This demonstrates that our API will automatically get the access token if it hasn't been specified. Now, when we run our application, this is what we see.

Image 1


By now, we're in a good place with our ability to call the CodeProject API, and cope with the resulting data. Again, our codebase is still assuming that everything is "in a happy place" and that we aren't going to be dealing with failures. Right now, I'm not worried about this because we are still getting the infrastructure in place to cope with this. At the end of the last article, I had thought that I would be tackling the articles APIs in this installation but as I set out to write the code, I had realised that "vanity" apps would be a good place to start so that everyone, even those who hadn't written any articles, would be able to benefit from the code.

The next step is to start bringing in some more of the APIs, and to start investigating whether or not we need to expose all of the interfaces as public interfaces or whether we can start to keep some of the implementations internal. We need to start tightening up the "surface area" that people see, using our API. I am also keen to start introducing other examples as well. Console applications are all well and good, but let's start to make things a bit more "bling".

At this point, I hope that I have managed to convey the thought processes and design decisions I have made. Again, did I meeet my objectives? Well, I'd like to think I did. I've tried to adhere to "best practices" but I've also tried to be pragmatic. Where compromises are made, I'll keep on documenting what they are and we'll hopefully keep addressing them as the code progresses.


No new article from me would be complete without the music listings. During the creation of the series, so far, I have listened to the following albums/artists.

  • Joe Satriani – The Complete Albums
  • Joe Bonamassa – Live From the Royal Albert Hall
  • Brad Gillis – Alligator
  • Pink Floyd – The Wall
  • Rush – Moving Pictures
  • Sixx A.M. – Modern Vintage
  • The Answer – Everyday Demons
  • Thunder – Wonder Days
  • Andrea Bocelli - Sogno
  • Andrea Bocelli - Opera
  • John Lee Hooker - Mr Lucky
  • John Lee Hooker - The Healer
  • Eric Clapton and BB King - Riding With The King


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


About the Author

Pete O'Hanlon
United Kingdom United Kingdom
A developer for over 30 years, I've been lucky enough to write articles and applications for Code Project as well as the Intel Ultimate Coder - Going Perceptual challenge. I live in the North East of England with 2 wonderful daughters and a wonderful wife.

I am not the Stig, but I do wish I had Lotus Tuned Suspension.

Comments and Discussions

QuestionA question on 'style' wrt Constants Pin
Garth J Lancaster17-Apr-16 15:59
professionalGarth J Lancaster17-Apr-16 15:59 
AnswerRe: A question on 'style' wrt Constants Pin
Pete O'Hanlon17-Apr-16 20:45
subeditorPete O'Hanlon17-Apr-16 20:45 
GeneralMy vote of 5 Pin
DrABELL13-Jun-15 15:45
professionalDrABELL13-Jun-15 15:45 
GeneralRe: My vote of 5 Pin
Pete O'Hanlon14-Jun-15 9:47
subeditorPete O'Hanlon14-Jun-15 9:47 
GeneralRe: My vote of 5 Pin
DrABELL14-Jun-15 11:14
professionalDrABELL14-Jun-15 11:14 
QuestionBest C# Article of May 2015 Pin
Sibeesh Passion10-Jun-15 19:18
professionalSibeesh Passion10-Jun-15 19:18 
AnswerRe: Best C# Article of May 2015 Pin
Pete O'Hanlon10-Jun-15 19:28
subeditorPete O'Hanlon10-Jun-15 19:28 
GeneralRe: Best C# Article of May 2015 Pin
Sibeesh Passion10-Jun-15 21:19
professionalSibeesh Passion10-Jun-15 21:19 
GeneralReusable Library Pin
Daniel Vaughan8-May-15 0:59
MemberDaniel Vaughan8-May-15 0:59 
GeneralRe: Reusable Library Pin
Pete O'Hanlon8-May-15 1:06
subeditorPete O'Hanlon8-May-15 1:06 
QuestionNaming Standard Pin
Stuart_King8-May-15 0:08
MemberStuart_King8-May-15 0:08 
AnswerRe: Naming Standard Pin
Pete O'Hanlon8-May-15 0:22
subeditorPete O'Hanlon8-May-15 0:22 
GeneralRe: Naming Standard Pin
Stuart_King8-May-15 0:31
MemberStuart_King8-May-15 0:31 
GeneralRe: Naming Standard Pin
Pete O'Hanlon8-May-15 0:46
subeditorPete O'Hanlon8-May-15 0:46 
GeneralRe: Naming Standard Pin
Daniel Vaughan8-May-15 0:54
MemberDaniel Vaughan8-May-15 0:54 
GeneralRe: Naming Standard Pin
Pete O'Hanlon8-May-15 0:56
subeditorPete O'Hanlon8-May-15 0:56 
GeneralRe: Naming Standard Pin
Daniel Vaughan8-May-15 1:04
MemberDaniel Vaughan8-May-15 1:04 
GeneralRe: Naming Standard Pin
Pete O'Hanlon8-May-15 1:07
subeditorPete O'Hanlon8-May-15 1:07 
GeneralAnother Great one! Pin
Agent__0077-May-15 17:36
professionalAgent__0077-May-15 17:36 
GeneralRe: Another Great one! Pin
Pete O'Hanlon7-May-15 22:35
subeditorPete O'Hanlon7-May-15 22:35 
QuestionI vote a 5 also... Pin
George McCormick7-May-15 16:56
MemberGeorge McCormick7-May-15 16:56 
AnswerRe: I vote a 5 also... Pin
Pete O'Hanlon7-May-15 22:36
subeditorPete O'Hanlon7-May-15 22:36 
GeneralMy vote of 5 Pin
linuxjr7-May-15 6:20
professionallinuxjr7-May-15 6:20 
GeneralRe: My vote of 5 Pin
Pete O'Hanlon7-May-15 6:24
subeditorPete O'Hanlon7-May-15 6:24 
QuestionAnother nice one Pin
Sacha Barber7-May-15 5:29
MemberSacha Barber7-May-15 5:29 

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.

Posted 6 May 2015


24 bookmarked