## [Unreleased] - 2016-12-12
### Added
+- Add inefficient API response
+- Add inefficient polling
+- Add comments
- JSDoc config
- Twitter OAuth Integration
- Simple contributing guidelines
--- /dev/null
+import Vue from 'vue';
+
+const internals = {};
+
+export default internals.CommentComponent = Vue.component('comment', {
+ template: '<article class="comment">' +
+ '<aside class="comment-meta">' +
+ '<img :src="comment.userImage" v-bind:alt="\'Avatar for @\' + comment.userId">' +
+ '<a v-bind:href="\'https://twitter.com/\' + comment.userId">{{comment.userName}}</a> said on ' +
+ '<time v-bind:datetime="comment.timestamp | datetime">{{comment.timestamp | usertime}}</time>' +
+ '</aside>' +
+ '<div class="comment-content">{{comment.content}}</div>' +
+ '</article>',
+
+ props: ['comment']
+});
+
--- /dev/null
+import Axios from 'axios';
+import Vue from 'vue';
+import AuthService from '../services/auth';
+
+const internals = {};
+
+internals.kPostsRoute = '/api/posts';
+internals.kCommentsRoute = '/comments';
+
+export default internals.CommentFormComponent = Vue.component('comment-form', {
+ template: '<div class="comment-form-container">' +
+ '<p v-show="!active" class="comment-form-error">' +
+ '<button class="comment-activate" v-on:click="activate">Add comment.</button>' +
+ '</p>' +
+ '<textarea v-show="active" :disabled="submitting" v-model="content" class="comment-content-input" placeholder="tell us something" maxlength=255></textarea>' +
+ '<p v-show="message" class="comment-form-error">{{message}}</p>' +
+ '<button v-show="active" :disabled="submitting" class="comment-submit" v-on:click="submit">Go.</button>' +
+ '</div>',
+
+ props: ['postUuid'],
+
+ data() {
+
+ return {
+ content: '',
+ message: '',
+ active: false,
+ submitting: false,
+ authService: new AuthService()
+ };
+ },
+
+ methods: {
+
+ // Activates the form.
+
+ activate() {
+
+ this.active = true;
+ },
+
+ // Creates a comment
+
+ submit() {
+
+ this.submitting = true;
+ const route = `${internals.kPostsRoute}/${this.postUuid}${internals.kCommentsRoute}`;
+
+ return Axios({
+ method: 'post',
+ headers: {
+ Authorization: `Bearer ${this.authService.token}`
+ },
+ data: {
+ content: this.content
+ },
+ url: route
+ }).then((response) => {
+
+ this.$emit('comment-submitted', response.data);
+ this.content = '';
+ this.message = '';
+ this.submitting = false;
+ this.active = false;
+ }).catch((err) => {
+
+ console.error(err.stack);
+ this.submitting = false;
+ this.message = 'Error while creating the post...';
+ });
+ }
+ }
+});
--- /dev/null
+import Axios from 'axios';
+import Vue from 'vue';
+import AuthService from '../services/auth';
+
+import CommentFormComponent from './comment_form';
+import CommentComponent from './comment';
+import DatetimeFilter from '../filters/datetime';
+import UsertimeFilter from '../filters/usertime';
+
+const internals = {};
+
+internals.kPostsRoute = '/api/posts';
+internals.kCommentsRoute = '/comments';
+internals.kPollFrequency = 2000;
+
+export default internals.CommentsComponent = Vue.component('comments', {
+ template: '<div class="comments-container">' +
+ '<p v-show="message" class="comments-error">{{message}}</p>' +
+ '<comment v-for="comment in comments" v-bind:comment="comment" :key="comment.uuid"></comment>' +
+ '<comment-form v-bind:postUuid="postUuid" v-on:comment-submitted="addComment"></comment-form>' +
+ '</div>',
+
+ props: ['postUuid'],
+
+ data() {
+
+ return {
+ message: '',
+ poller: null,
+ authService: new AuthService(),
+ comments: []
+ };
+ },
+
+ methods: {
+ fetchComments() {
+
+ const route = `${internals.kPostsRoute}/${this.postUuid}${internals.kCommentsRoute}`;
+
+ return Axios({
+ method: 'get',
+ headers: {
+ Authorization: `Bearer ${this.authService.token}`
+ },
+ url: route
+ }).then((response) => {
+
+ this.comments = response.data;
+ if (!this._isBeingDestroyed) {
+ setTimeout(this.fetchComments.bind(this), internals.kPollFrequency);
+ }
+ }).catch((err) => {
+
+ console.error(err.stack);
+ this.message = 'Error while loading the comments...';
+ });
+ },
+
+ addComment(comment) {
+
+ this.comments.push(comment);
+ }
+ },
+
+ components: {
+ commentForm: CommentFormComponent,
+ comment: CommentComponent,
+ datetime: DatetimeFilter,
+ usertime: UsertimeFilter
+ },
+
+ mounted() {
+
+ this.fetchComments();
+ }
+});
--- /dev/null
+import Vue from 'vue';
+
+import CommentsComponent from './comments';
+
+const internals = {};
+
+export default internals.PostComponent = Vue.component('post', {
+ template: '<article class="post">' +
+ '<aside class="post-meta">' +
+ '<img :src="post.userImage" v-bind:alt="\'Avatar for @\' + post.userId">' +
+ '<a v-bind:href="\'https://twitter.com/\' + post.userId">{{post.userName}}</a> said on ' +
+ '<time v-bind:datetime="post.timestamp | datetime">{{post.timestamp | usertime}}</time>' +
+ '</aside>' +
+ '<div class="post-content">{{post.content}}</div>' +
+ '<comments v-bind:postUuid="post.uuid"></post>' +
+ '</article>',
+
+ props: ['post'],
+
+ components: {
+ comments: CommentsComponent
+ }
+});
export default internals.PostFormComponent = Vue.component('post-form', {
template: '<div class="post-form-container">' +
- '<h1>sup.</h1>' +
- '<textarea :disabled="submitting" v-model="content" class="post-content-input" placeholder="tell us something" maxlength=255></textarea>' +
+ '<h1>hi {{authService.user.name}}</h1>' +
+ '<button v-show="!active" class="post-submit" v-on:click="activate">Post something.</button>' +
+ '<textarea v-show="active" :disabled="submitting" v-model="content" class="post-content-input" placeholder="tell us something" maxlength=255></textarea>' +
'<p v-show="message" class="post-form-error">{{message}}</p>' +
- '<button :disabled="submitting" class="post-submit" v-on:click="submit">Go.</button>' +
+ '<button v-show="active" :disabled="submitting" class="post-submit" v-on:click="submit">Go.</button>' +
'</div>',
data() {
content: '',
message: '',
submitting: false,
+ active: false,
authService: new AuthService()
};
},
methods: {
+
+ // Activates the form.
+
+ activate() {
+
+ this.active = true;
+ },
+
+ // Creates a post
+
submit() {
this.submitting = true;
this.content = '';
this.message = '';
this.submitting = false;
+ this.active = false;
}).catch((err) => {
console.error(err.stack);
import AuthService from '../services/auth';
import PostFormComponent from './post_form';
+import PostComponent from './post';
import DatetimeFilter from '../filters/datetime';
import UsertimeFilter from '../filters/usertime';
const internals = {};
internals.kPostsRoute = '/api/posts';
+internals.kPollFrequency = 5000;
export default internals.PostsComponent = Vue.component('posts', {
- template: '<div class="posts-container">' +
+ template: '<div v-if="authService.authenticated" class="posts-container">' +
'<post-form v-on:post-submitted="addPost"></post-form>' +
'<h1>Posts.</h1>' +
'<p v-show="message" class="posts-error">{{message}}</p>' +
- '<article class="post" v-for="post in posts">' +
- '<aside class="post-meta">' +
- '<img :src="post.userImage" v-bind:alt="\'Avatar for @\' + post.userId">' +
- '<a v-bind:href="\'https://twitter.com/\' + post.userId">{{post.userName}}</a> said on ' +
- '<time v-bind:datetime="post.timestamp | datetime">{{post.timestamp | usertime}}</time>' +
- '</aside>' +
- '<div class="post-content">{{post.content}}</div>' +
- '</article>' +
+ '<post v-for="post in posts" v-bind:post="post" :key="post.uuid"></post>' +
'</div>',
data() {
}).then((response) => {
this.posts = response.data;
+ if (!this._isBeingDestroyed) {
+ setTimeout(this.fetchPosts.bind(this), internals.kPollFrequency);
+ }
}).catch((err) => {
- console.err(err.stack);
+ console.error(err.stack);
this.message = 'Error while loading the posts...';
});
},
components: {
postForm: PostFormComponent,
+ post: PostComponent,
datetime: DatetimeFilter,
usertime: UsertimeFilter
},
- mounted: function mounted() {
+ mounted() {
if (!this.authService.authenticated) {
return this.$router.push('/login');
}
- return this.fetchPosts();
+ this.fetchPosts();
+
}
});
this.vm = new Vue({
router: this._setupRouter(),
el: '#dasein',
- components: {
- login: LoginComponent,
- welcome: WelcomeComponent
- },
- data: {
- message: 'Hello Vue!'
- },
methods: {
authenticated() {
const AuthHandler = require('./handlers/auth');
const PostsHandler = require('./handlers/posts');
+const CommentsHandler = require('./handlers/comments');
const internals = {};
this._initializeAuthRoutes();
this._initializePostsRoutes();
+ this._initializeCommentsRoutes();
this._app.use(function * () {
}
+ // Initialize routes for comments
+
+ _initializeCommentsRoutes() {
+
+ const commentsHandler = new CommentsHandler({
+ ttl: this.ttl,
+ redis: this.redis
+ });
+ this._app.use(KoaRoute.get('/api/posts/:postId/comments', commentsHandler.findAll()));
+ this._app.use(KoaRoute.post('/api/posts/:postId/comments', commentsHandler.create()));
+ }
+
// Starts listening
_startServer() {
--- /dev/null
+'use strict';
+
+const Joi = require('joi');
+const Pify = require('pify');
+const Redis = require('redis');
+const UUID = require('uuid/v4');
+
+const internals = {};
+
+internals.kPostsPrefix = 'posts';
+internals.kCommentsPrefix = 'comments';
+internals.kMaxCommentSize = 255;
+
+internals.kCommentsSchema = Joi.object().keys({
+ uuid: Joi.string().required(),
+ content: Joi.string().max(internals.kMaxCommentSize).required(),
+ timestamp: Joi.number().integer().required(),
+ userId: Joi.string().required(),
+ userName: Joi.string().required(),
+ userImage: Joi.string().required()
+});
+
+/**
+ * Handles the HTTP requests for comment related operations
+ *
+ * @class CommentsHandler
+ * @param {Dasein.tConfiguration} config The configuration to
+ * initialize.
+ */
+module.exports = internals.CommentsHandler = class CommentsHandler {
+ constructor(config) {
+
+ this._ttl = config.ttl;
+ this._redis = Redis.createClient(config.redis);
+
+ // Log an error if it happens.
+ this._redis.on('error', (err) => {
+
+ console.error(err);
+ });
+ }
+
+ /**
+ * Fetches all available comments
+ *
+ * @function findAll
+ * @memberof CommentsHandler
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ findAll() {
+
+ const self = this;
+
+ return function * (postId) {
+
+ if (!this.state.user) {
+ return this.throw('Unauthorized', 401);
+ }
+
+ const scan = Pify(self._redis.scan.bind(self._redis));
+ const hgetall = Pify(self._redis.hgetall.bind(self._redis));
+
+ const commentsKey = `${internals.kCommentsPrefix}:${postId}:*`;
+ let keys = [];
+ let nextCursor = 0;
+ let currentKeys = null;
+
+ do {
+ [nextCursor, currentKeys] = yield scan(nextCursor || 0, 'MATCH', commentsKey);
+ keys = keys.concat(currentKeys);
+ } while (nextCursor > 0);
+
+ const comments = yield keys.map((key) => hgetall(key));
+
+ this.body = comments.sort((a, b) => a.timestamp - b.timestamp);
+ };
+ }
+
+ /**
+ * Creates a comment
+ *
+ * @function create
+ * @memberof CommentsHandler
+ * @instance
+ * @return {generator} a koa compatible handler generator function
+ */
+ create() {
+
+ const self = this;
+
+ return function * (postId) {
+
+ if (!this.state.user) {
+ return this.throw('Unauthorized', 401);
+ }
+
+ const hmset = Pify(self._redis.hmset.bind(self._redis));
+ const hgetall = Pify(self._redis.hgetall.bind(self._redis));
+ const expire = Pify(self._redis.expire.bind(self._redis));
+
+ const uuid = UUID();
+ const timestamp = Date.now();
+ const user = this.state.user;
+
+ const postKey = `${internals.kPostsPrefix}:${postId}`;
+ const commentKey = `${internals.kCommentsPrefix}:${postId}:${uuid}`;
+
+ const comment = {
+ uuid,
+ content: this.request.body.content,
+ timestamp,
+ userId: user.screen_name,
+ userName: user.name,
+ userImage: user.profile_image_url_https
+ };
+
+ yield self._validate(comment).catch((err) => {
+
+ this.throw(err.message, 422);
+ });
+
+ yield hmset(commentKey, comment);
+ yield expire(commentKey, self._ttl * 100); // this is me being lazy :(
+ // comments will last at most 100 bumps
+ // but will disappear eventually
+ yield expire(postKey, self._ttl); // bumps the parent comment TTL
+
+ this.body = yield hgetall(commentKey);
+ };
+ }
+
+ // Validates the comment schema
+
+ _validate(comment) {
+
+ const validate = Pify(Joi.validate.bind(Joi));
+ return validate(comment, internals.kCommentsSchema);
+ }
+};
const scan = Pify(self._redis.scan.bind(self._redis));
const hgetall = Pify(self._redis.hgetall.bind(self._redis));
- const cursor = parseInt(this.request.query.cursor) || 0;
- const [nextCursor, keys] = yield scan(cursor, 'MATCH', `${internals.kPostsPrefix}:*`);
+ let keys = [];
+ let nextCursor = 0;
+ let currentKeys = null;
- if (nextCursor > 0) {
- this.append('Link', `<${this.request.origin}${this.request.path}?cursor=${nextCursor}>; rel="next"`);
- }
+ do {
+ [nextCursor, currentKeys] = yield scan(nextCursor || 0, 'MATCH', `${internals.kPostsPrefix}:*`);
+ keys = keys.concat(currentKeys);
+ } while (nextCursor > 0);
const posts = yield keys.map((key) => hgetall(key));
- this.body = posts;
+ this.body = posts.sort((a, b) => b.timestamp - a.timestamp);
};
}
/* The stream */
+.comments-container .comment .comment-meta,
.posts-container .post .post-meta {
background-color: lightgray;
display: flex;
align-items: center;
+ font-size: 20px;
}
+.comment-meta time, .comment-meta a,
.post-meta time, .post-meta a {
padding: 0 10px;
}
+.comments-container .comment .comment-content,
+.comment-form-container .comment-content-input,
+.comment-form-container .comment-submit,
.posts-container .post .post-content,
.post-form-container .post-content-input,
.post-form-container .post-submit {
padding: 10px;
}
+.comments-container .comment .comment-content,
+.posts-container .post .post-content {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.comment-form-container .comment-content-input,
.post-form-container .post-content-input {
display: block;
margin: 10px;
height: 140px;
}
+.comment-form-container .comment-submit,
+.comment-form-container .comment-activate,
.post-form-container .post-submit {
margin: 10px auto;
display: block;
}
.posts-error,
+.comments-error,
.post-form-error {
color: red;
padding: 10px;
}
+
+/* Comments */
+
+.comments-container .comment .comment-meta {
+ background-color: ghostwhite;
+ font-size: 16px;
+}
+
+.comments-container .comment .comment-meta img {
+ width: 24px;
+ height: 24px;
+}
+
+.comments-container .comment .comment-content,
+.comment-form-container .comment-content-input,
+.comment-form-container .comment-submit {
+ font-size: 16px;
+}
+
+.comment-form-container .comment-activate {
+ padding: 2px;
+ font-size: 12px;
+}
+
+.comment-form-container .comment-content-input {
+ height: 70px;
+}
+
+.comment-form-container .comment-activate:active,
+.comment-form-container .comment-submit:active {
+ background-color: magenta;
+}