]> git.r.bdr.sh - rbdr/dasein/commitdiff
Add Comments (#4)
authorRubén Beltrán del Río <redacted>
Tue, 31 Jan 2017 06:49:26 +0000 (00:49 -0600)
committerGitHub <redacted>
Tue, 31 Jan 2017 06:49:26 +0000 (00:49 -0600)
* 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

13 files changed:
CHANGELOG.md
app/components/comment.js [new file with mode: 0644]
app/components/comment_form.js [new file with mode: 0644]
app/components/comments.js [new file with mode: 0644]
app/components/post.js [new file with mode: 0644]
app/components/post_form.js
app/components/posts.js
app/dasein.js
etc/dasein.paw
lib/dasein.js
lib/handlers/comments.js [new file with mode: 0644]
lib/handlers/posts.js
static/css/app.css

index 425a9d3332d6509962d5fe69c198ba7b373eed80..fd3847522df6546812c776b589a9b40401ece1a2 100644 (file)
@@ -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 (file)
index 0000000..33ce2c7
--- /dev/null
@@ -0,0 +1,17 @@
+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']
+});
+
diff --git a/app/components/comment_form.js b/app/components/comment_form.js
new file mode 100644 (file)
index 0000000..bf60d06
--- /dev/null
@@ -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: '<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...';
+      });
+    }
+  }
+});
diff --git a/app/components/comments.js b/app/components/comments.js
new file mode 100644 (file)
index 0000000..e29cedc
--- /dev/null
@@ -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: '<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();
+  }
+});
diff --git a/app/components/post.js b/app/components/post.js
new file mode 100644 (file)
index 0000000..bb72189
--- /dev/null
@@ -0,0 +1,23 @@
+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
+  }
+});
index cee17b0785e8bceea716e7e79139acfe3d1694d6..1f45f0c53f4b222cfb3427e4a89c0db7f5c36f27 100644 (file)
@@ -8,10 +8,11 @@ internals.kPostsRoute = '/api/posts';
 
 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() {
@@ -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);
index c41707fc7e71b8e3963bb807b50994af5ca6c8d9..d0e03387d9f207f5885d58f395970d87bd3c4229 100644 (file)
@@ -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: '<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() {
@@ -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();
+
   }
 });
 
index 6b3fd951e8143df8eebb6df20b024dd0d55e09ae..699a01f3cfa854c2cd20ea92f9417f36c587ecae 100644 (file)
@@ -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() {
 
index 9983dad1a171915fa45dbb861313ed045a87f987..632b02b430d6120887370fd361e43cb7dbf0e770 100644 (file)
Binary files a/etc/dasein.paw and b/etc/dasein.paw differ
index 3a537bd8ccdf9d11b33e0d888bc269cfa5b8b838..5b8486a09806a132be9ae8d262b39c331a677313 100644 (file)
@@ -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 (file)
index 0000000..14bd137
--- /dev/null
@@ -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);
+  }
+};
index 425fb5e76fddb573ae2282978961998cb1de968c..b5e4f0ee65d4ba99483969f15fd8829ab7f46305 100644 (file)
@@ -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);
     };
   }
 
index 34712e30e2ca82b462c855bcbd45d2bbbcb4e5af..2177c513c1977b178a2f752a6a5a0c66a91a65fe 100644 (file)
@@ -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;
+}