About half-year ago I read Don Williamson's blog post on how to replace Disqus comments engine with GitHub Comments (in russian). "Brilliant idea!", I thought, and I was inspired to implement same comments support for my blog some day. The day has come!
Yes, really, I've used Disqus for this blog from the start but never liked it much. Increased page load time, so many requests to unfamiliar and strange URLs - nothing good for me and the readers, I think.
Now I had a few spare time to implement comments with GitHub API. I had decided to touch with Vue.js for presentation and learn the way to implement reusable components with it.
UPDATE 2019-10-28: I stopped being happy with poi.js after a year. Poi.js has released a couple of major versions with incompatible breaking changes. Workflow with tool also has changed, documentation has fallen behind. Therefore I've decided to refuse poi.js and vue.js in favour of proven react.js + mobx. This post must be considered as outdated.
Other posts in the series
- Creating reusable components with Vue.js - Part 1 - Tooling overview
- Creating reusable components with Vue.js - Part 2 - Viewing GitHub Issue Comments This post
- GitHub comments - Part 3 - Overcoming GitHub REST API v3 restrictions
Table of contents
- Designing comments view component
- Relative date component
- Avatar view component
- GitHub comment view component
- GitHub comments list view component
- Conclusion
Designing comments view component
Let's look at the typical comments view component. It will list all the comments for some page. Each comment has an author, comment text and publishing date.
First of all, we can identify "comment view" component and use it to render individual comments in list:
We can try to divide this component more:
- "avatar" component, we can reuse it in comment posting form in future
- "relative date" component (
22 days ago
on image above)
Relative date component
We'd like to pass some date to the component and it should render the string representation of this date relative to current date. Let's require to render date as relative if difference is less than month, and as ISO date otherwise. We'll store passed date in ISO format and in human-readable format in span's attribute just like GitHub does:
Let's create RelativeTime.vue component:
<template>
<span
:date="this.date.toISOString()"
:title="this.date.toLocaleString()">
{{this.relativeDate}}
</span>
</template>
<script>
export default {
name: 'relative-time',
props: {
date: {
default: new Date(),
type: Date,
required: true
}
},
computed: {
relativeDate: function () {
const oneMinute = 60*1000
const oneHour = 60*oneMinute
const oneDay = 24*oneHour
const oneMonth = 30*oneDay
const difference = new Date() - this.date
if (difference < oneMinute) {
return 'just now'
} else if (difference < oneHour) {
return Math.floor(difference/oneMinute) + ' minutes ago'
} else if (difference < oneDay) {
return Math.floor(difference/oneHour) + ' hours ago'
} else if (difference < oneMonth) {
return Math.floor(difference/oneDay) + ' days ago'
} else {
return 'on ' + this.date.toLocaleString()
}
}
}
}
</script>
*.vue files are single file components, they consists of three sections: template, script and style. Each section is optional. You can specify language of each section with lang
attribute. The defaults is lang="html"
for template, lang="javascript"
for script and lang="css"
for style. If you use vue-loader
and correctly configure your webpack build script, you'll be able, for example, to override defaults to lang="jade"
for template, lang="typescript"
for script section, lang="less"
for style. Select what suits your needs, it's fully customizable!
The component receives single property date
. We declare that property inside props
: it should be Date
, has default value and the value should be required.
Then we declare computed property relativeDate
which is javascript function for calculation date relativeness to string.
Both date
property and the relativeDate
propery are used in template. Vue.js is smart enough to track changes in date
property to update reactiveDate
property value properly.
Avatar view component
Let's create AvatarView.vue component:
<template>
<div class="avatar-container">
<a :href="userUrl" rel="nofollow" target="_blank">
<img :src="imageUrl" :alt="user" class="avatar"/>
</a>
</div>
</template>
<script>
export default {
name: 'avatar-view',
props: [
'user',
'userUrl',
'imageUrl'
]
}
</script>
<style scoped>
.avatar-container {
border-radius: 5px;
float: left;
position: relative;
margin-left: -65px;
}
.avatar {
width: 50px;
height: 50px;
vertical-align: middle;
border-radius: 5px;
border-style: none;
display: inline-block;
}
</style>
The "avatar view" component receives three properties:
- user name
- GitHub's user profile URL
- user's avatar image
There is no any required properties, so we'll left them without any declaration. We'll just use these properties in template and we need some styling to position avatar in the left. Nothing complicated here.
GitHub comment view component
Let's compose the avatar view and the relative date components to visualize single comment in GithubCommentView.vue component. I'll use bootstrap list-group-item
styles here:
<template>
<div>
<avatar-view
:user="userLogin"
:userUrl="userProfileUrl"
:imageUrl="userAvatarUrl"
></avatar-view>
<ul :id="'issue-comment-' + commentId" class="list-group">
<li class="list-group-item list-group-item-info header">
<strong>
<a :href="userProfileUrl" rel="nofollow" target="_blank">
{{userLogin}}
</a>
</strong>
commented
<a :href="'#issue-comment-' + commentId" rel="nofollow">
<relative-time :date="publishDate"></relative-time>
</a>
</li>
<li class="list-group-item">
<div v-html="commentHtmlBody"></div>
</li>
</ul>
</div>
</template>
<script>
import AvatarView from './AvatarView'
import RelativeTime from './RelativeTime'
export default {
name: 'github-comment-view',
props: [
'commentId',
'userLogin',
'userProfileUrl',
'userAvatarUrl',
'publishDate',
'commentHtmlBody'
],
components: {
RelativeTime,
AvatarView
},
}
</script>
Unfortunately, template section expect the only one child node inside. Therefore we need to wrap multiple nodes in the root <div>
.
You can easily insert HTML parts with v-html
directive. Note that you should know what you doing - rendering data from user as HTML is potential security risk that allows for XSS attacks. I'll receive HTML data for this component from trusted GitHub API, therefore it's allowed.
This component (like RelativeTime and AvatarView) is "pure" - it just receives some properties and render HTML markup with them. The flow of properties directed from parent component to child.
GitHub comments list view component
We need to request issue comments using GitHub Issues Comments API. The HTTP request is simple: GET /repos/:owner/:repo/issues/:number/comments
, no authentication and authorization needed.
Despite we can use AJAX requests to fetch the data, I decided to find ready convenient JavaScript client library. There is official list of client libraries for many languages, several wrappers written in JavaScript. I've selected octokat.js because it supports promises out of the box. It is small and can easily run in browser. I definitely recommend it to use!
The requirements for GitHub comments list component is:
- show spinner icon when there is pending GitHub API request runs
- load first portion of comments when component is created
- "show more comments" button should load next portions of comments if possible
Here is the GithubCommentsListView.vuecomponent
<template>
<div>
<ul class="comments-list">
<li v-for="comment in comments" class="comment-block" :key="comment.id">
<github-comment-view
:commentId="comment.id"
:userLogin="comment.user.login"
:userProfileUrl="comment.user.htmlUrl"
:userAvatarUrl="comment.user.avatarUrl"
:publishDate="comment.createdAt"
:commentHtmlBody="comment.bodyHtml"
></github-comment-view>
</li>
<li class="comment-block">
<div v-if="showLoader">
<i class="fa fa-spin fa-spinner fa-fw fa-2x"></i>
<span class="sr-only">Loading...</span>
</div>
<template v-if="!showLoader && canShowMoreComments">
<button class="btn btn-success" @click="loadMoreComments">Show more comments</button>
<span> of comments shown</span>
</template>
<template v-if="!showLoader && !canShowMoreComments">
<span> comments shown</span>
</template>
</li>
</ul>
</div>
</template>
<script>
import Octokat from 'octokat'
import GithubCommentView from './GithubCommentView'
export default {
name: 'github-comments-list-view',
props: [
'apiRoot',
'owner',
'repository',
'issueNumber'
],
components: {
GithubCommentView
},
data: function () {
return {
comments: [],
overallCommentsCount: 0,
canShowMoreComments: false,
showLoader: false
}
},
created: async function() {
this.showLoader = true
const octo = new Octokat({
rootURL: this.apiRoot,
acceptHeader: 'application/vnd.github.v3.html+json'
})
const issueRoot = octo
.repos(this.owner, this.repository)
.issues(this.issueNumber)
const issue = await issueRoot.fetch()
this.overallCommentsCount = issue.comments
const comments = await issueRoot.comments.fetch()
this.addComments(comments)
},
methods: {
addComments: function (comments) {
this.comments = this.comments.concat(comments.items)
this.nextComments = comments.nextPage
this.canShowMoreComments = !!comments.nextPage
this.showLoader = false
},
loadMoreComments: function () {
if (!this.showLoader && this.nextComments) {
this.showLoader = true
this.nextComments.fetch().then(this.addComments)
}
}
}
}
</script>
<style scoped>
.comments-list {
list-style-type: none;
}
.comment-block {
margin: 20px 20px 20px 80px;
}
</style>
This component is not pure. It's aggregation root for comments list and holds state: current loaded list of comments.
I've used created
lifecycle function to load first portion of comments. It is necessary to configure octokat.js to accept application/vnd.github.v3.html+json
MIME type so we can receive plain HTML instead of markdown for comments body.
The addComments
method pushes loaded comments to array and resets showLoader
flag. If you can load more comments, nextComments
will be filled and "Show more comments" button will be shown.
In the template, GitHub comments rendered from array using v-for
directive. It worth to mention key
attribute which exists to help Vue.js to reuse existing elements if underlying array has been changed.
Why I pass individual properties to github-comment-view
not the GitHub's comment? Because of open-closed principle. github-comment-view
has well-defined interface and closed for possible changes in Octokat or GitHub API.
For markup, I used boostrap styles and spinner icon from FontAwesome.
Conclusion
It was interesting journey to reusable components and it's not over. You can find the source code for this blog post on GitHub.
There are multiple topics out of this blog post like:
- state managed using stores with Vuex
- modifying markup and styles of ready components
- DI, slots, plugins and mixins
In the next post I'll explore how to integrate GitHub Issue Comments view component to Jekyll-based blog.
Happy commenting!
Comments