From: Rubén Beltrán del Río Date: Tue, 31 Jan 2017 06:49:26 +0000 (-0600) Subject: Add Comments (#4) X-Git-Url: https://git.r.bdr.sh/rbdr/dasein/commitdiff_plain/9b1f50fefe021578113b8f5935084fe4fed110b2?ds=sidebyside Add Comments (#4) * Preserve whitespace in posts * Create comments handler * Use comments handler * Update paw * Add comment components * Style comments * Remove unnecessary code from app * Fix linter warning * Add key to handle children (thx @javierbyte) * Add toggling to the form * Return all keys always * Correct missing semicolons * Restore redirect * Render posts conditionally * Set poll time to 1s * Set post frequency to 2s * Add polling to comments * Correct typo in error logging * Clear intervals and reduce polling * Stops polling if it's being destroyed * Update changelog --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 425a9d3..fd38475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] - 2016-12-12 ### Added +- Add inefficient API response +- Add inefficient polling +- Add comments - JSDoc config - Twitter OAuth Integration - Simple contributing guidelines diff --git a/app/components/comment.js b/app/components/comment.js new file mode 100644 index 0000000..33ce2c7 --- /dev/null +++ b/app/components/comment.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; + +const internals = {}; + +export default internals.CommentComponent = Vue.component('comment', { + template: '
' + + '' + + '
{{comment.content}}
' + + '
', + + props: ['comment'] +}); + diff --git a/app/components/comment_form.js b/app/components/comment_form.js new file mode 100644 index 0000000..bf60d06 --- /dev/null +++ b/app/components/comment_form.js @@ -0,0 +1,73 @@ +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: '
' + + '

' + + '' + + '

' + + '' + + '

{{message}}

' + + '' + + '
', + + 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...'; + }); + } + } +}); diff --git a/app/components/comments.js b/app/components/comments.js new file mode 100644 index 0000000..e29cedc --- /dev/null +++ b/app/components/comments.js @@ -0,0 +1,76 @@ +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: '
' + + '

{{message}}

' + + '' + + '' + + '
', + + 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(); + } +}); diff --git a/app/components/post.js b/app/components/post.js new file mode 100644 index 0000000..bb72189 --- /dev/null +++ b/app/components/post.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; + +import CommentsComponent from './comments'; + +const internals = {}; + +export default internals.PostComponent = Vue.component('post', { + template: '
' + + '' + + '
{{post.content}}
' + + '' + + '
', + + props: ['post'], + + components: { + comments: CommentsComponent + } +}); diff --git a/app/components/post_form.js b/app/components/post_form.js index cee17b0..1f45f0c 100644 --- a/app/components/post_form.js +++ b/app/components/post_form.js @@ -8,10 +8,11 @@ internals.kPostsRoute = '/api/posts'; export default internals.PostFormComponent = Vue.component('post-form', { template: '
' + - '

sup.

' + - '' + + '

hi {{authService.user.name}}

' + + '' + + '' + '

{{message}}

' + - '' + + '' + '
', data() { @@ -20,11 +21,22 @@ export default internals.PostFormComponent = Vue.component('post-form', { content: '', message: '', submitting: false, + active: false, authService: new AuthService() }; }, methods: { + + // Activates the form. + + activate() { + + this.active = true; + }, + + // Creates a post + submit() { this.submitting = true; @@ -44,6 +56,7 @@ export default internals.PostFormComponent = Vue.component('post-form', { this.content = ''; this.message = ''; this.submitting = false; + this.active = false; }).catch((err) => { console.error(err.stack); diff --git a/app/components/posts.js b/app/components/posts.js index c41707f..d0e0338 100644 --- a/app/components/posts.js +++ b/app/components/posts.js @@ -3,26 +3,21 @@ import Vue from 'vue'; 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: '
' + + template: '
' + '' + '

Posts.

' + '

{{message}}

' + - '
' + - '' + - '
{{post.content}}
' + - '
' + + '' + '
', data() { @@ -46,9 +41,12 @@ export default internals.PostsComponent = Vue.component('posts', { }).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...'; }); }, @@ -61,17 +59,19 @@ export default internals.PostsComponent = Vue.component('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(); + } }); diff --git a/app/dasein.js b/app/dasein.js index 6b3fd95..699a01f 100644 --- a/app/dasein.js +++ b/app/dasein.js @@ -22,13 +22,6 @@ export default internals.Dasein = { this.vm = new Vue({ router: this._setupRouter(), el: '#dasein', - components: { - login: LoginComponent, - welcome: WelcomeComponent - }, - data: { - message: 'Hello Vue!' - }, methods: { authenticated() { diff --git a/etc/dasein.paw b/etc/dasein.paw index 9983dad..632b02b 100644 Binary files a/etc/dasein.paw and b/etc/dasein.paw differ diff --git a/lib/dasein.js b/lib/dasein.js index 3a537bd..5b8486a 100644 --- a/lib/dasein.js +++ b/lib/dasein.js @@ -10,6 +10,7 @@ const Path = require('path'); const AuthHandler = require('./handlers/auth'); const PostsHandler = require('./handlers/posts'); +const CommentsHandler = require('./handlers/comments'); const internals = {}; @@ -90,6 +91,7 @@ module.exports = internals.Dasein = class Dasein { this._initializeAuthRoutes(); this._initializePostsRoutes(); + this._initializeCommentsRoutes(); this._app.use(function * () { @@ -125,6 +127,18 @@ module.exports = internals.Dasein = class Dasein { } + // 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() { diff --git a/lib/handlers/comments.js b/lib/handlers/comments.js new file mode 100644 index 0000000..14bd137 --- /dev/null +++ b/lib/handlers/comments.js @@ -0,0 +1,140 @@ +'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); + } +}; diff --git a/lib/handlers/posts.js b/lib/handlers/posts.js index 425fb5e..b5e4f0e 100644 --- a/lib/handlers/posts.js +++ b/lib/handlers/posts.js @@ -60,16 +60,18 @@ module.exports = internals.PostsHandler = class PostsHandler { 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); }; } diff --git a/static/css/app.css b/static/css/app.css index 34712e3..2177c51 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -47,16 +47,22 @@ section { /* 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 { @@ -68,6 +74,13 @@ section { 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; @@ -76,6 +89,8 @@ section { height: 140px; } +.comment-form-container .comment-submit, +.comment-form-container .comment-activate, .post-form-container .post-submit { margin: 10px auto; display: block; @@ -87,7 +102,40 @@ section { } .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; +}