Custom comment engine support in MiniBlog

I recently (rather crudely) added support for Disqus to my fork of
MiniBlog
.  The post detailing
this can be found
here.  The implementation detailed in the previous post was fairly crude.  I wanted
to improve upon it a little bit, so that if someone wanted to add
support for another comment engine, it wouldn’t be too hard.

NB. Please be aware, this post describes how I extended MiniBlog to have the ability to support multiple comment systems.  This is a simple implementation and should not be taken as an example of how to do things.

ICommentEngine

I needed a way to identify different comment engines, so I created
ICommentEngine.

/// <summary>
/// Interface that represents a comment engine
/// </summary>
public interface ICommentEngine  
{
    /// <summary>
    /// Name of the comment engine
    /// </summary>
    string Name { get; }

    /// <summary>
    /// Path to the comment section.
    /// Convention is: ~/views/commentengine/{name}/{name}.cshtml
    /// </summary>
    string CommentSectionPath { get; }

    /// <summary>
    /// Path to the comment count section.
    /// Convention is: ~/views/commentengine/{name}/commentcount.cshtml
    /// </summary>
    string CommentCountSectionPath { get; }

    /// <summary>
    /// Path to the global section
    /// Convention is: ~/views/commentengine/{name}/global.cshtml
    /// </summary>
    string GlobalSectionPath { get; }

    /// <summary>
    /// Method to render the comments section
    /// </summary>
    /// <param name="post">The current post</param>
    /// <param name="context">The context</param>
    /// <returns><see cref="HelperResult"/></returns>
    HelperResult RenderCommentSection(Post post, HttpContext context);

    /// <summary>
    /// Renders the comment count section
    /// </summary>
    /// <param name="post">The current post</param>
    /// <param name="context">The context</param>
    /// <returns><see cref="HelperResult"/></returns>
    HelperResult RenderCommentCountSection(Post post, HttpContext context);

    /// <summary>
    /// Renders the global section.  This section is put on every page before the closing body tag. 
    /// </summary>
    /// <param name="context">The context</param>
    /// <returns><see cref="HelperResult"/></returns>
    HelperResult RenderGlobalSection(HttpContext context);

    /// <summary>
    /// Gets settings related to the selected blog engine from the web.config and caches them.
    /// Convention: <add key="{name}:{key}" value="VALUE"/>
    /// </summary>
    /// <param name="key">Key to look for.  This key is concatenated with the comment engine name in the format: name:key</param>
    /// <returns>The setting vale</returns>
    string GetSetting(string key);

    /// <summary>
    /// Loads the comments and returns them
    /// </summary>
    /// <param name="doc"></param>
    /// <returns></returns>
    IEnumerable<Comment> LoadComments(XElement doc = null);

    /// <summary>
    /// Whether comments are open
    /// </summary>
    /// <param name="post"></param>
    /// <param name="contextBase"></param>
    /// <returns></returns>
    bool AreCommentsOpen(Post post, HttpContextBase contextBase);

    /// <summary>
    /// Counts the approved comments
    /// </summary>
    /// <param name="post"></param>
    /// <param name="context"></param>
    /// <returns></returns>
    int CountApprovedComments(Post post, HttpContextBase context);
}

CommentEngineBase

In order to keep things simple, I have tried to make this as
convention-based as possible.  To facilitate this, I created an abstract
base class which does most of the heavy lifting.

public abstract class CommentEngineBase : ICommentEngine  
{
    private string _globalSectionPath;
    private string _commentSectionPath;
    private string _commentCountSectionPath;
    private static ConcurrentDictionary<string, string> _settings = new ConcurrentDictionary<string, string>(); 

    /// <summary>
    /// Name of the comment engine
    /// </summary>
    public abstract string Name { get; }

    /// <summary>
    /// Path to the comment section.
    /// Convention is: ~/views/commentengine/{name}/{name}.cshtml
    /// </summary>
    public virtual string CommentSectionPath
    {
        get
        {
            return _commentSectionPath ??
                   (_commentSectionPath = string.Format("~/views/commentengines/{0}/{0}.cshtml", Name));
        }
        set
        {
            _commentSectionPath = value; 

        }
    }

    /// <summary>
    /// Path to the comment count section.
    /// Convention is: ~/views/commentengine/{name}/commentcount.cshtml
    /// </summary>
    public virtual string CommentCountSectionPath
    {
        get
        {
            return _commentCountSectionPath ??
                   (_commentCountSectionPath = string.Format("~/views/commentengines/{0}/commentcount.cshtml", Name));
        }
        set
        {
            _commentCountSectionPath = value; 

        }
    }

    /// <summary>
    /// Path to the global section
    /// Convention is: ~/views/commentengine/{name}/global.cshtml
    /// </summary>
    public string GlobalSectionPath
    {
        get {
            return _globalSectionPath ??
                   (_globalSectionPath = string.Format("~/views/commentengines/{0}/global.cshtml", Name));
        }
        set
        {
            _globalSectionPath = value;

        }
    }

    /// <summary>
    /// Method to render the comments section
    /// </summary>
    /// <param name="post">The current post</param>
    /// <param name="context">The context</param>
    /// <returns><see cref="HelperResult"/></returns>
    public virtual HelperResult RenderCommentSection(Post post, HttpContext context)
    {
        var contextWrapper = new HttpContextWrapper(context);
        return RenderHelperResult(CommentSectionPath, new
        {
            Comments = post.Comments,
            ApprovedCommentCount = this.CountApprovedComments(post, contextWrapper),
            CommentsOpen = this.AreCommentsOpen(post, contextWrapper)
        }, context);
    }

    /// <summary>
    /// Renders the comment count section
    /// </summary>
    /// <param name="post">The current post</param>
    /// <param name="context">The context</param>
    /// <returns><see cref="HelperResult"/></returns>
    public HelperResult RenderCommentCountSection(Post post, HttpContext context)
    {
        return RenderHelperResult(CommentCountSectionPath, new CommentCount(1, post.Url), context);
    }

    /// <summary>
    /// Renders the global section.  This section is put on every page before the closing body tag. 
    /// </summary>
    /// <param name="context">The context</param>
    /// <returns><see cref="HelperResult"/></returns>
    public virtual HelperResult RenderGlobalSection(HttpContext context)
    {
        return RenderHelperResult(GlobalSectionPath, null, context);
    }

    /// <summary>
    /// Gets settings related to the selected blog engine from the web.config and caches them.
    /// Convention: <add key="{name}:{key}" value="VALUE"/>
    /// </summary>
    /// <param name="key">Key to look for.  This key is concatenated with the comment engine name in the format: name:key</param>
    /// <returns>The setting vale</returns>
    public string GetSetting(string settingKey)
    {
        var key = string.Format("{0}:{1}", Name, settingKey);

        // check if setting is cached already
        if (_settings.ContainsKey(key))
        {
            return _settings[key];
        }
        else
        {
            // Fetch from config
            var fromConfiguration = ConfigurationManager.AppSettings.Get(key);

            if (fromConfiguration != null)
            {
                _settings.TryAdd(key, fromConfiguration);
                return fromConfiguration;
            }
        }
        return string.Empty;
    }

    /// <summary>
    /// Loads the comments and returns them
    /// </summary>
    /// <param name="doc"></param>
    /// <returns></returns>
    public virtual IEnumerable<Comment> LoadComments(XElement doc = null)
    {
        return new List<Comment>();
    }

    /// <summary>
    /// Counts the approved comments
    /// </summary>
    /// <param name="post"></param>
    /// <param name="context"></param>
    /// <returns></returns>
    public virtual int CountApprovedComments(Post post, HttpContextBase context)
    {
        return post.Comments.Count(c => c.IsApproved);
    }

    /// <summary>
    /// Whether comments are open
    /// </summary>
    /// <param name="post"></param>
    /// <param name="context"></param>
    /// <returns></returns>
    public virtual bool AreCommentsOpen(Post post, HttpContextBase context)
    {
        return true;
    }

    /// <summary>
    /// Renders a view page
    /// </summary>
    /// <param name="pageUrl">Url to render</param>
    /// <param name="model">Model to pass into view</param>
    /// <param name="context">Http Context</param>
    /// <returns><see cref="HelperResult"/></returns>
    protected HelperResult RenderHelperResult(string pageUrl, object model, HttpContext context)
    {   
        if (!File.Exists(context.Server.MapPath(VirtualPathUtility.ToAbsolute(pageUrl))))
        {
            return null;
        }

        return new HelperResult(writer =>
        {
            var page =
                (WebPage)WebPageBase.CreateInstanceFromVirtualPath(pageUrl);
            page.Context = new HttpContextWrapper(context);
            page.ExecutePageHierarchy(new WebPageContext(page.Context, page: null, model: model), writer);
        });
    }
}

This class is the core logic behind the comments system.  As you can see
it is convention based around the location of the views.

CommentEngineFactory

There is also a CommentEngineFactory which is responsible for
creating the correct comment engine.  It is a convention-based factory
that will look for any class with a name that you specify in the
web.config which also implements CommentEngineBase.  This design
will allow for easy creation of future comment engines.

public class CommentEngineFactory  
{
    public static ICommentEngine Create(string engineType)
    {
        foreach (var a in AppDomain.CurrentDomain.GetAssemblies())
        {
            foreach (var t in a.GetTypes())
            {
                if (t.IsSubclassOf(typeof(CommentEngineBase)))
                {
                    if (t.Name.Equals(engineType, StringComparison.InvariantCultureIgnoreCase) 
                        || t.Name.Equals(engineType + "CommentEngine", StringComparison.InvariantCultureIgnoreCase))
                    {
                        return (ICommentEngine)Activator.CreateInstance(t);
                    }
                }
            }
        }
        throw new Exception("Unable to locate comment engine");
    }
}

Blog Class Changes

The Blog class is the core class of MiniBlog.  I have altered it to
contain the selected comment engine.

public static class Blog  
{
    static Blog()
    {
        Theme = ConfigurationManager.AppSettings.Get("blog:theme");
        Title = ConfigurationManager.AppSettings.Get("blog:name");
        Description = ConfigurationManager.AppSettings.Get("blog:description");
        PostsPerPage = int.Parse(ConfigurationManager.AppSettings.Get("blog:postsPerPage"));
        DaysToComment = int.Parse(ConfigurationManager.AppSettings.Get("blog:daysToComment"));
        Image = ConfigurationManager.AppSettings.Get("blog:image");
        ModerateComments = bool.Parse(ConfigurationManager.AppSettings.Get("blog:moderateComments"));
        GoogleAnalyticsId = ConfigurationManager.AppSettings.Get("blog:googleAnalyticsId");
        GoogleAnalyticsDomain = ConfigurationManager.AppSettings.Get("blog:googleAnalyticsDomain");

        CommentEngine = CommentEngineFactory.Create(ConfigurationManager.AppSettings.Get("blog:commentEngine"));
    }

    public static string Title { get; private set; }
    public static string Description { get; private set; }
    public static string Theme { get; private set; }
    public static string Image { get; private set; }
    public static int PostsPerPage { get; private set; }
    public static int DaysToComment { get; private set; }
    public static bool ModerateComments { get; private set; }
    public static string GoogleAnalyticsId { get; set; }
    public static string GoogleAnalyticsDomain { get; set; }
    public static ICommentEngine CommentEngine { get; set; }

View Changes

I have also made further changes to the views.  In particular the
post.cshtml view.

@using System.Web.UI.WebControls
<article class="post" data-id="@Model.ID" itemscope itemtype="http://schema.org/BlogPosting" itemprop="blogPost">  
    <header class="jumbotron">
        <h1 itemprop="headline name">
            <a href="@Model.Url" itemprop="url">@Model.Title</a>
        </h1>
        <div>
            <abbr title="@Model.PubDate.ToLocalTime()" itemprop="datePublished">@Model.PubDate.ToLocalTime().ToString("MMMM d. yyyy")</abbr>

            @Blog.CommentEngine.RenderCommentCountSection(Model, HttpContext.Current)

            @Categories()
        </div>
    </header>

    <div itemprop="articleBody">@Html.Raw(Model.Content)</div>

    @if (Blog.CurrentPost != null)
    {
        @Blog.CommentEngine.RenderCommentSection(Model, HttpContext.Current)        
    }
</article>

@helper Categories()
{
    if (Model.Categories.Length > 0 || User.Identity.IsAuthenticated)
    {
        <ul class="categories">
            <li><em class="glyphicon glyphicon-tags"></em>&nbsp; Posted in: </li>
            @foreach (string cat in Model.Categories)
            {
                <li itemprop="articleSection">
                    <a href="~/category/@HttpUtility.UrlEncode(cat.ToLowerInvariant())">@cat</a>
                </li>
            }
        </ul>
    }
}

The changes here are fairly obvious and are easily transferable to all
of the themes.

Inbuilt Implementation

The first task was to implement the inbuilt comment engine.

public class InBuiltCommentEngine : CommentEngineBase  
{
    /// <summary>
    /// Name of the comment engine
    /// </summary>
    public override string Name
    {
        get { return "inbuilt"; }
    }

    /// <summary>
    /// Loads the comments and returns them
    /// </summary>
    /// <param name="doc"></param>
    /// <returns></returns>
    public override IEnumerable<Comment> LoadComments(XElement doc = null)
    {
        return Storage.LoadComments(doc);
    }

    /// <summary>
    /// Whether comments are open
    /// </summary>
    /// <param name="post"></param>
    /// <param name="context"></param>
    /// <returns></returns>
    public override bool AreCommentsOpen(Post post, HttpContextBase context)
    {
        return post.PubDate > DateTime.UtcNow.AddDays(-Blog.DaysToComment) || context.User.Identity.IsAuthenticated;
    }

    /// <summary>
    /// Counts the approved comments
    /// </summary>
    /// <param name="post"></param>
    /// <param name="context"></param>
    /// <returns></returns>
    public override int CountApprovedComments(Post post, HttpContextBase context)
    {
        return (Blog.ModerateComments && !context.User.Identity.IsAuthenticated) ? post.Comments.Count(c => c.IsApproved) : post.Comments.Count;
    }
}

Views

The views were next.  As I mentioned previously, the design is
convention-based.  So you only need to drop some cshtml files into the
right location.

inbuilt.cshtml

@if (Model.ApprovedCommentCount > 0)
{
    <h2>Comments</h2>
}

<section id="comments" aria-label="Comments">  
    @foreach (Comment comment in Model.Comments)
    {
        if (comment.IsApproved || !Blog.ModerateComments || Context.User.Identity.IsAuthenticated)
        {
            @RenderPage("~/views/CommentEngines/inbuilt/comment.cshtml", comment)
        }
    }
</section>

@if (Model.CommentsOpen)
{
    @RenderPage("~/views/CommentForm.cshtml")
}

comment.cshtml

<article data-id="@Model.ID" itemscope itemtype="http://schema.org/UserComments" itemprop="comment" class="@(Model.IsAdmin ? "self" : null)">  
    <img src="@Model.GravatarUrl(50)" width="50" height="50" alt="Comment by @Model.Author" />
    <div>
        @Date()
        <p itemprop="commentText">@Html.Raw(Model.ContentWithLinks())</p>
        @Author()
        @DeleteAndApproveButton()
    </div>
    @ApprovalMessage()
</article>

@helper Date()
{
    var title = Model.PubDate.ToString("yyyy-MM-ddTHH:mm");
    var display = Model.PubDate.ToString("MMMM d. yyyy HH:mm");
    <time datetime="@title" itemprop="commentTime">@display</time>
}
@helper Author()
{
    if (string.IsNullOrEmpty(Model.Website))
    {
        <strong itemprop="creator">@Model.Author</strong>
    }
    else
    {
        <strong itemprop="creator"><a href="@Model.Website" itemprop="url" rel="nofollow">@Model.Author</a></strong>
    }
}
@helper DeleteAndApproveButton()
{
    if (User.Identity.IsAuthenticated)
    {
        <button class="deletecomment btn btn-link">Delete</button>
        if (Blog.ModerateComments && !Model.IsApproved)
        {
            <button class="approvecomment btn btn-link">Approve</button>
        }
    }
}
@helper ApprovalMessage()
{
    if (Blog.ModerateComments && !Model.IsApproved && !User.Identity.IsAuthenticated)
    {
        <div itemprop="approvalWarning">! The comment will not be visible until a moderator approves it !</div>
    }
}

commentcount.cshtml – This page is responsible for showing the comment counts

<a href="@Model.PostUri#comments">  
    <em class="glyphicon glyphicon-comment"></em>
    @Model.Count Comments
</a>  

The commentcount page also requires it’s own new view model.  This is
below.

/// <summary>
/// Summary description for CommentCount
/// </summary>
public class CommentCount  
{
    private readonly int _count;
    private readonly Uri _uri;

    public CommentCount(int count, Uri uri)
    {
        _count = count;
        _uri = uri;
    }

    public int Count
    {
        get { return _count; }
    }

    public Uri Uri
    {
        get { return _uri; }
    }
}

Disqus Implementation

And now for the Disqus implementation.   It is very similar to the
inbuilt one above in terms of architecture (as it is convention based).

DisqusCommentEngine.cs

public class DisqusCommentEngine : CommentEngineBase  
{
    public override string Name
    {
        get { return "Disqus"; }
    }
}

disqus.cshtml

<h2>Comments</h2>  
<section id="comments" aria-label="Comments">  
    <div id="disqus_thread"></div>
</section>  
<script type="text/javascript">  
    /* * * CONFIGURATION VARIABLES: EDIT BEFORE PASTING INTO YOUR WEBPAGE * * */
    var disqus_shortname = '@Blog.CommentEngine.GetSetting("shortname")'; // required: replace example with your forum shortname

    /* * * DON'T EDIT BELOW THIS LINE * * */
    (function () {
        var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true;
        dsq.src = '//' + disqus_shortname + '.disqus.com/embed.js';
        (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
    })();
</script>  
<noscript>Please enable JavaScript to view the <a href="http://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>  
<a href="http://disqus.com" class="dsq-brlink">comments powered by <span class="logo-disqus">Disqus</span></a>  

commentcount.cshtml

<a href="@Model.Uri#disqus_thread">  
    <em class="glyphicon glyphicon-comment"></em>
    0 comments
</a>  

I have left the text “0 comments” here because DIsqus will
asynchronously load the comment count based on the Uri and alter that
text.  So before that has happened, I want to display something to the
user.

global.cshtml – Executed on every page

<script type="text/javascript">  
    /* * * CONFIGURATION VARIABLES: EDIT BEFORE PASTING INTO YOUR WEBPAGE * * */
    var disqus_shortname = '@Blog.CommentEngine.GetSetting("shortname")'; // required: replace example with your forum shortname

    /* * * DON'T EDIT BELOW THIS LINE * * */
    (function () {
        var s = document.createElement('script'); s.async = true;
        s.type = 'text/javascript';
        s.src = '//' + disqus_shortname + '.disqus.com/count.js';
        (document.getElementsByTagName('HEAD')[0] || document.getElementsByTagName('BODY')[0]).appendChild(s);
    }());
</script>  

The global section is a view that can be rendered onto the bottom of
every page, if required by the comment engine.  Disqus uses this to
calculate the comment counts.  If this view is not required by the
comment engine, simply do not add it, and it will be ignored.  As we saw
above, the inbuilt comment engine does not use this feature.  This
feature required a single line to be added to index.cshtml in the root
of the site.

...

@AntiForgery.GetHtml()

@Blog.CommentEngine.RenderGlobalSection(HttpContext.Current)

if (!Request.IsLocal)

...

Creating your own comment engine

Creating your own comment engine is very simple, just follow the steps
below.

  • Create an implementation of CommentEngineBase
  • In your web.config reference your implementation.
    • <add key="blog:commentEngine" value="YOURCLASSNAME"/>
  • Add the appropriate views
    • ~/views/commentengines/yourclassname/yourclassname.cshtml
    • ~/views/commentengines/yourclassname/commentcount.cshtml
    • ~/views/commentengines/yourclassname/global.cshtml (optional)

Conclusion

All of the code for the above can be found on my GitHub fork of
MiniBlog.  If you spot any issues with the implementation, please let me
know.