From fe982aa5876ff25b6678f658c033e15ecc4a03c5 Mon Sep 17 00:00:00 2001
From: Toby Zerner <toby.zerner@gmail.com>
Date: Tue, 17 Mar 2015 17:06:12 +1030
Subject: [PATCH] Add user activity system

---
 .../core/ember/app/adapters/application.js    |  7 ++
 .../app/components/user/activity-item.js      | 12 +++
 .../app/components/user/activity-post.js      |  9 ++
 .../ember/app/controllers/user/activity.js    | 71 +++++++++++++++
 framework/core/ember/app/models/activity.js   | 11 +++
 framework/core/ember/app/router.js            |  2 -
 .../core/ember/app/routes/user/activity.js    | 12 +++
 .../core/ember/app/styles/flarum/user.less    | 73 +++++++++++++++
 .../components/user/activity-item.hbs         |  1 +
 .../components/user/activity-join.hbs         |  6 ++
 .../components/user/activity-post.hbs         | 21 +++++
 framework/core/ember/app/templates/user.hbs   |  4 +-
 .../ember/app/templates/user/activity.hbs     | 13 +++
 framework/core/ember/app/views/user.js        | 22 ++++-
 ...015_02_24_000000_create_activity_table.php |  6 +-
 .../src/Api/Actions/Activity/IndexAction.php  | 47 ++++++++++
 .../Api/Actions/Discussions/ShowAction.php    |  6 +-
 ...tsPostsForDiscussion.php => GetsPosts.php} | 12 +--
 .../src/Api/Actions/Posts/IndexAction.php     | 13 ++-
 .../Api/Serializers/ActivitySerializer.php    | 68 +++++++++++---
 .../src/Api/Serializers/PostSerializer.php    |  2 +-
 .../core/src/Core/CoreServiceProvider.php     |  8 ++
 framework/core/src/Core/Models/Activity.php   | 89 +++++++++++++------
 .../ActivityRepositoryInterface.php           |  8 ++
 .../EloquentActivityRepository.php            | 39 ++++++++
 .../Repositories/EloquentPostRepository.php   | 10 +--
 .../Repositories/PostRepositoryInterface.php  |  8 +-
 27 files changed, 508 insertions(+), 72 deletions(-)
 create mode 100644 framework/core/ember/app/components/user/activity-item.js
 create mode 100644 framework/core/ember/app/components/user/activity-post.js
 create mode 100644 framework/core/ember/app/controllers/user/activity.js
 create mode 100644 framework/core/ember/app/models/activity.js
 create mode 100644 framework/core/ember/app/routes/user/activity.js
 create mode 100644 framework/core/ember/app/templates/components/user/activity-item.hbs
 create mode 100644 framework/core/ember/app/templates/components/user/activity-join.hbs
 create mode 100644 framework/core/ember/app/templates/components/user/activity-post.hbs
 create mode 100644 framework/core/ember/app/templates/user/activity.hbs
 create mode 100644 framework/core/src/Api/Actions/Activity/IndexAction.php
 rename framework/core/src/Api/Actions/Posts/{GetsPostsForDiscussion.php => GetsPosts.php} (60%)
 create mode 100644 framework/core/src/Core/Repositories/ActivityRepositoryInterface.php
 create mode 100644 framework/core/src/Core/Repositories/EloquentActivityRepository.php

diff --git a/framework/core/ember/app/adapters/application.js b/framework/core/ember/app/adapters/application.js
index 8487c2565..351aac8d8 100644
--- a/framework/core/ember/app/adapters/application.js
+++ b/framework/core/ember/app/adapters/application.js
@@ -7,6 +7,13 @@ import AlertMessage from 'flarum/components/ui/alert-message';
 export default JsonApiAdapter.extend({
   host: config.apiURL,
 
+  pathForType: function(type) {
+    if (type == 'activity') {
+      return type;
+    }
+    return this._super(type);
+  },
+
   ajaxError: function(jqXHR) {
     var errors = this._super(jqXHR);
 
diff --git a/framework/core/ember/app/components/user/activity-item.js b/framework/core/ember/app/components/user/activity-item.js
new file mode 100644
index 000000000..01428fd58
--- /dev/null
+++ b/framework/core/ember/app/components/user/activity-item.js
@@ -0,0 +1,12 @@
+import Ember from 'ember';
+
+import FadeIn from 'flarum/mixins/fade-in';
+
+export default Ember.Component.extend(FadeIn, {
+  layoutName: 'components/user/activity-item',
+  tagName: 'li',
+
+  componentName: Ember.computed('activity.type', function() {
+    return 'user/activity-'+this.get('activity.type');
+  })
+});
diff --git a/framework/core/ember/app/components/user/activity-post.js b/framework/core/ember/app/components/user/activity-post.js
new file mode 100644
index 000000000..bbd26d1e4
--- /dev/null
+++ b/framework/core/ember/app/components/user/activity-post.js
@@ -0,0 +1,9 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  layoutName: 'components/user/activity-post',
+
+  isFirstPost: Ember.computed('activity.post.number', function() {
+    return this.get('activity.post.number') === 1;
+  })
+});
diff --git a/framework/core/ember/app/controllers/user/activity.js b/framework/core/ember/app/controllers/user/activity.js
new file mode 100644
index 000000000..6efe75ad0
--- /dev/null
+++ b/framework/core/ember/app/controllers/user/activity.js
@@ -0,0 +1,71 @@
+import Ember from 'ember';
+
+export default Ember.Controller.extend({
+  needs: ['user'],
+
+  queryParams: ['filter'],
+  filter: '',
+
+  resultsLoading: false,
+
+  moreResults: true,
+
+  loadCount: 10,
+
+  getResults: function(start) {
+    var type;
+    switch (this.get('filter')) {
+      case 'discussions':
+        type = 'discussion';
+        break;
+
+      case 'posts':
+        type = 'post';
+        break;
+    }
+    var controller = this;
+    return this.store.find('activity', {
+      users: this.get('controllers.user.model.id'),
+      type: type,
+      start: start,
+      count: this.get('loadCount')
+    }).then(function(results) {
+      controller.set('moreResults', results.get('length') >= controller.get('loadCount'));
+      return results;
+    });
+  },
+
+  paramsDidChange: Ember.observer('filter', function() {
+    if (this.get('model') && !this.get('resultsLoading')) {
+      Ember.run.once(this, this.loadResults);
+    }
+  }),
+
+  loadResults: function() {
+    this.send('loadResults');
+  },
+
+  actions: {
+    loadResults: function() {
+      var controller = this;
+      controller.get('model').set('content', []);
+      controller.set('resultsLoading', true);
+      controller.getResults().then(function(results) {
+        controller
+          .set('resultsLoading', false)
+          .set('meta', results.get('meta'))
+          .set('model.content', results);
+      });
+    },
+
+    loadMore: function() {
+      var controller = this;
+      this.set('resultsLoading', true);
+      this.getResults(this.get('model.length')).then(function(results) {
+        controller.get('model.content').addObjects(results);
+        controller.set('meta', results.get('meta'));
+        controller.set('resultsLoading', false);
+      });
+    },
+  }
+});
diff --git a/framework/core/ember/app/models/activity.js b/framework/core/ember/app/models/activity.js
new file mode 100644
index 000000000..bc6c9fc1b
--- /dev/null
+++ b/framework/core/ember/app/models/activity.js
@@ -0,0 +1,11 @@
+import DS from 'ember-data';
+
+export default DS.Model.extend({
+  type: DS.attr('string'),
+  content: DS.attr('string'),
+  time: DS.attr('date'),
+
+  user: DS.belongsTo('user'),
+  sender: DS.belongsTo('user'),
+  post: DS.belongsTo('post')
+});
diff --git a/framework/core/ember/app/router.js b/framework/core/ember/app/router.js
index 2f8398d42..89bd4f337 100644
--- a/framework/core/ember/app/router.js
+++ b/framework/core/ember/app/router.js
@@ -14,8 +14,6 @@ Router.map(function() {
 
   this.resource('user', {path: '/u/:username'}, function() {
     this.route('activity', {path: '/'});
-    this.route('discussions');
-    this.route('posts');
     this.route('edit');
   });
 
diff --git a/framework/core/ember/app/routes/user/activity.js b/framework/core/ember/app/routes/user/activity.js
new file mode 100644
index 000000000..75e94045a
--- /dev/null
+++ b/framework/core/ember/app/routes/user/activity.js
@@ -0,0 +1,12 @@
+import Ember from 'ember';
+
+export default Ember.Route.extend({
+  model: function() {
+    return Ember.RSVP.resolve(Ember.ArrayProxy.create());
+  },
+
+  setupController: function(controller, model) {
+    controller.set('model', model);
+    controller.send('loadResults');
+  }
+});
diff --git a/framework/core/ember/app/styles/flarum/user.less b/framework/core/ember/app/styles/flarum/user.less
index fa687b444..c001fd6f5 100644
--- a/framework/core/ember/app/styles/flarum/user.less
+++ b/framework/core/ember/app/styles/flarum/user.less
@@ -95,3 +95,76 @@
     }
   }
 }
+
+.user-content .loading-indicator {
+  height: 46px;
+}
+.user-activity {
+  border-left: 3px solid @fl-body-secondary-color;
+  list-style: none;
+  margin: 0 0 0 16px;
+  padding: 0;
+
+  & > li {
+    margin-bottom: 30px;
+    padding-left: 32px;
+  }
+  & .activity-icon {
+    .avatar-size(32px);
+    float: left;
+    margin-left: -49px;
+    .box-shadow(0 0 0 3px #fff);
+    margin-top: -5px;
+  }
+}
+.activity-info {
+  color: @fl-body-muted-color;
+  margin-bottom: 10px;
+
+  & strong {
+    margin-right: 5px;
+  }
+}
+.activity-content {
+  display: block;
+  padding: 20px;
+  background: @fl-body-secondary-color;
+  border-radius: @border-radius-base;
+  color: @fl-body-muted-color;
+
+  &, &:hover {
+    text-decoration: none;
+  }
+  & .discussion-summary {
+    margin: -20px 0;
+    padding-left: 0;
+
+    & .author {
+      display: none;
+    }
+  }
+}
+.activity-post {
+  overflow: hidden;
+
+  & .title {
+    margin: 0 0 10px;
+    font-size: 14px;
+    font-weight: bold;
+
+    &, & a {
+      color: @fl-body-heading-color;
+    }
+  }
+  &:hover .title {
+    text-decoration: underline;
+  }
+  & .body {
+    color: @fl-body-muted-color;
+    line-height: 1.7em;
+
+    & :last-child {
+      margin-bottom: 0;
+    }
+  }
+}
diff --git a/framework/core/ember/app/templates/components/user/activity-item.hbs b/framework/core/ember/app/templates/components/user/activity-item.hbs
new file mode 100644
index 000000000..2311dacf6
--- /dev/null
+++ b/framework/core/ember/app/templates/components/user/activity-item.hbs
@@ -0,0 +1 @@
+{{component componentName activity=activity}}
diff --git a/framework/core/ember/app/templates/components/user/activity-join.hbs b/framework/core/ember/app/templates/components/user/activity-join.hbs
new file mode 100644
index 000000000..3ffafbfb8
--- /dev/null
+++ b/framework/core/ember/app/templates/components/user/activity-join.hbs
@@ -0,0 +1,6 @@
+{{user-avatar activity.user class="activity-icon"}}
+
+<div class="activity-info">
+  <strong>Joined the forum</strong>
+  {{human-time activity.time}}
+</div>
diff --git a/framework/core/ember/app/templates/components/user/activity-post.hbs b/framework/core/ember/app/templates/components/user/activity-post.hbs
new file mode 100644
index 000000000..f58019629
--- /dev/null
+++ b/framework/core/ember/app/templates/components/user/activity-post.hbs
@@ -0,0 +1,21 @@
+{{user-avatar activity.post.user class="activity-icon"}}
+
+<div class="activity-info">
+  <strong>{{if isFirstPost "Started a discussion" "Posted a reply"}}</strong>
+  {{human-time activity.time}}
+</div>
+
+{{#if isFirstPost}}
+  <div class="activity-content activity-discussion">
+    {{index/discussion-listing discussion=activity.post.discussion}}
+  </div>
+{{else}}
+  {{#link-to "discussion" activity.post.discussion (query-params start=activity.post.number) class="activity-content activity-post"}}
+    <h3 class="title">
+      {{activity.post.discussion.title}}
+    </h3>
+    <div class="body">
+      {{{activity.post.contentHtml}}}
+    </div>
+  {{/link-to}}
+{{/if}}
diff --git a/framework/core/ember/app/templates/user.hbs b/framework/core/ember/app/templates/user.hbs
index fdada9a5a..dea7c6a91 100644
--- a/framework/core/ember/app/templates/user.hbs
+++ b/framework/core/ember/app/templates/user.hbs
@@ -5,5 +5,7 @@
     {{ui/item-list items=view.sidebar}}
   </nav>
 
-  {{outlet}}
+  <div class="offset-content user-content">
+    {{outlet}}
+  </div>
 </div>
diff --git a/framework/core/ember/app/templates/user/activity.hbs b/framework/core/ember/app/templates/user/activity.hbs
new file mode 100644
index 000000000..c09debb2f
--- /dev/null
+++ b/framework/core/ember/app/templates/user/activity.hbs
@@ -0,0 +1,13 @@
+<ul class="user-activity">
+  {{#each activity in model}}
+    {{user/activity-item activity=activity}}
+  {{/each}}
+</ul>
+
+{{#if resultsLoading}}
+  {{ui/loading-indicator size="small"}}
+{{else if moreResults}}
+  <div class="load-more">
+    {{ui/action-button class="control-loadMore btn btn-default" action="loadMore" label="Load More"}}
+  </div>
+{{/if}}
diff --git a/framework/core/ember/app/views/user.js b/framework/core/ember/app/views/user.js
index 77b7b9980..10036b422 100644
--- a/framework/core/ember/app/views/user.js
+++ b/framework/core/ember/app/views/user.js
@@ -9,6 +9,22 @@ var precompileTemplate = Ember.Handlebars.compile;
 export default Ember.View.extend(HasItemLists, {
   itemLists: ['sidebar'],
 
+  didInsertElement: function() {
+    // Affix the sidebar so that when the user scrolls down it will stick
+    // to the top of their viewport.
+    var $sidebar = this.$('.user-nav');
+    $sidebar.find('> ul').affix({
+      offset: {
+        top: function () {
+          return $sidebar.offset().top - $('#header').outerHeight(true) - parseInt($sidebar.css('margin-top'));
+        },
+        bottom: function () {
+          return (this.bottom = $('#footer').outerHeight(true));
+        }
+      }
+    });
+  },
+
   populateSidebar: function(items) {
     var nav = this.populateItemList('nav');
     items.pushObjectWithTag(DropdownSelect.extend({items: nav, listItemClass: 'title-control'}), 'nav');
@@ -18,7 +34,7 @@ export default Ember.View.extend(HasItemLists, {
     items.pushObjectWithTag(NavItem.extend({
       label: 'Activity',
       icon: 'user',
-      layout: precompileTemplate('{{#link-to "user.activity"}}{{fa-icon icon}} {{label}}{{/link-to}}')
+      layout: precompileTemplate('{{#link-to "user.activity" (query-params filter="")}}{{fa-icon icon}} {{label}}{{/link-to}}')
     }), 'activity');
 
     items.pushObjectWithTag(NavItem.extend({
@@ -26,7 +42,7 @@ export default Ember.View.extend(HasItemLists, {
       icon: 'reorder',
       badge: Ember.computed.alias('user.discussionsCount'),
       user: this.get('controller.model'),
-      layout: precompileTemplate('{{#link-to "user.discussions"}}{{fa-icon icon}} {{label}} <span class="count">{{badge}}</span>{{/link-to}}')
+      layout: precompileTemplate('{{#link-to "user.activity" (query-params filter="discussions")}}{{fa-icon icon}} {{label}} <span class="count">{{badge}}</span>{{/link-to}}')
     }), 'discussions');
 
     items.pushObjectWithTag(NavItem.extend({
@@ -34,7 +50,7 @@ export default Ember.View.extend(HasItemLists, {
       icon: 'comment-o',
       badge: Ember.computed.alias('user.commentsCount'),
       user: this.get('controller.model'),
-      layout: precompileTemplate('{{#link-to "user.posts"}}{{fa-icon icon}} {{label}} <span class="count">{{badge}}</span>{{/link-to}}')
+      layout: precompileTemplate('{{#link-to "user.activity" (query-params filter="posts")}}{{fa-icon icon}} {{label}} <span class="count">{{badge}}</span>{{/link-to}}')
     }), 'posts');
   }
 });
diff --git a/framework/core/migrations/2015_02_24_000000_create_activity_table.php b/framework/core/migrations/2015_02_24_000000_create_activity_table.php
index 43169a606..401847afa 100644
--- a/framework/core/migrations/2015_02_24_000000_create_activity_table.php
+++ b/framework/core/migrations/2015_02_24_000000_create_activity_table.php
@@ -16,12 +16,10 @@ class CreateActivityTable extends Migration {
 		{
 			$table->increments('id');
 			$table->integer('user_id')->unsigned();
-			$table->integer('from_user_id')->unsigned()->nullable();
-			$table->string('subject');
-			$table->integer('subject_id')->unsigned()->nullable();
+			$table->integer('sender_id')->unsigned()->nullable();
+			$table->string('type');
 			$table->binary('data')->nullable();
 			$table->dateTime('time');
-			$table->boolean('is_read')->default(0);
 		});
 	}
 
diff --git a/framework/core/src/Api/Actions/Activity/IndexAction.php b/framework/core/src/Api/Actions/Activity/IndexAction.php
new file mode 100644
index 000000000..7f658a7d0
--- /dev/null
+++ b/framework/core/src/Api/Actions/Activity/IndexAction.php
@@ -0,0 +1,47 @@
+<?php namespace Flarum\Api\Actions\Activity;
+
+use Flarum\Core\Repositories\UserRepositoryInterface;
+use Flarum\Core\Repositories\ActivityRepositoryInterface;
+use Flarum\Core\Support\Actor;
+use Flarum\Api\Actions\BaseAction;
+use Flarum\Api\Actions\ApiParams;
+use Flarum\Api\Serializers\ActivitySerializer;
+
+class IndexAction extends BaseAction
+{
+    /**
+     * Instantiate the action.
+     *
+     * @param  \Flarum\Core\Search\Discussions\UserSearcher  $searcher
+     */
+    public function __construct(Actor $actor, UserRepositoryInterface $users, ActivityRepositoryInterface $activity)
+    {
+        $this->actor = $actor;
+        $this->users = $users;
+        $this->activity = $activity;
+    }
+
+    /**
+     * Show a user's activity feed.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    protected function run(ApiParams $params)
+    {
+        $start = $params->start();
+        $count = $params->count(20, 50);
+        $type  = $params->get('type');
+        $id    = $params->get('users');
+
+        $user = $this->users->findOrFail($id, $this->actor->getUser());
+
+        $activity = $this->activity->findByUser($user->id, $this->actor->getUser(), $count, $start, $type);
+
+        // Finally, we can set up the activity serializer and use it to create
+        // a collection of activity results.
+        $serializer = new ActivitySerializer(['sender', 'post', 'post.discussion', 'post.user', 'post.discussion.startUser', 'post.discussion.lastUser'], ['user']);
+        $document = $this->document()->setPrimaryElement($serializer->collection($activity));
+
+        return $this->respondWithDocument($document);
+    }
+}
diff --git a/framework/core/src/Api/Actions/Discussions/ShowAction.php b/framework/core/src/Api/Actions/Discussions/ShowAction.php
index 635fab3c2..e89aca139 100644
--- a/framework/core/src/Api/Actions/Discussions/ShowAction.php
+++ b/framework/core/src/Api/Actions/Discussions/ShowAction.php
@@ -5,12 +5,12 @@ use Flarum\Core\Repositories\DiscussionRepositoryInterface as DiscussionReposito
 use Flarum\Core\Repositories\PostRepositoryInterface as PostRepository;
 use Flarum\Api\Actions\BaseAction;
 use Flarum\Api\Actions\ApiParams;
-use Flarum\Api\Actions\Posts\GetsPostsForDiscussion;
+use Flarum\Api\Actions\Posts\GetsPosts;
 use Flarum\Api\Serializers\DiscussionSerializer;
 
 class ShowAction extends BaseAction
 {
-    use GetsPostsForDiscussion;
+    use GetsPosts;
 
     /**
      * The discussion repository.
@@ -51,7 +51,7 @@ class ShowAction extends BaseAction
 
         if (in_array('posts', $include)) {
             $relations = ['user', 'user.groups', 'editUser', 'hideUser'];
-            $discussion->posts = $this->getPostsForDiscussion($params, $discussion->id)->load($relations);
+            $discussion->posts = $this->getPosts($params, ['discussion_id' => $discussion->id])->load($relations);
 
             $include = array_merge($include, array_map(function ($relation) {
                 return 'posts.'.$relation;
diff --git a/framework/core/src/Api/Actions/Posts/GetsPostsForDiscussion.php b/framework/core/src/Api/Actions/Posts/GetsPosts.php
similarity index 60%
rename from framework/core/src/Api/Actions/Posts/GetsPostsForDiscussion.php
rename to framework/core/src/Api/Actions/Posts/GetsPosts.php
index f4a096b70..89febc4b3 100644
--- a/framework/core/src/Api/Actions/Posts/GetsPostsForDiscussion.php
+++ b/framework/core/src/Api/Actions/Posts/GetsPosts.php
@@ -3,23 +3,23 @@
 use Flarum\Core\Models\User;
 use Flarum\Api\Actions\ApiParams;
 
-trait GetsPostsForDiscussion
+trait GetsPosts
 {
-	protected function getPostsForDiscussion(ApiParams $params, $discussionId)
+	protected function getPosts(ApiParams $params, $where)
 	{
 		$sort = $params->sort(['time']);
         $count = $params->count(20, 50);
         $user = $this->actor->getUser();
 
-        if (($near = $params->get('near')) > 1) {
-            $start = $this->posts->getIndexForNumber($discussionId, $near, $user);
+        if (isset($where['discussion_id']) && ($near = $params->get('near')) > 1) {
+            $start = $this->posts->getIndexForNumber($where['discussion_id'], $near, $user);
             $start = max(0, $start - $count / 2);
         } else {
             $start = 0;
         }
 
-        return $this->posts->findByDiscussion(
-            $discussionId,
+        return $this->posts->findWhere(
+            $where,
             $user,
             $sort['field'],
             $sort['order'] ?: 'asc',
diff --git a/framework/core/src/Api/Actions/Posts/IndexAction.php b/framework/core/src/Api/Actions/Posts/IndexAction.php
index 11f8685ba..45e465e23 100644
--- a/framework/core/src/Api/Actions/Posts/IndexAction.php
+++ b/framework/core/src/Api/Actions/Posts/IndexAction.php
@@ -9,7 +9,7 @@ use Flarum\Api\Serializers\PostSerializer;
 
 class IndexAction extends BaseAction
 {
-    use GetsPostsForDiscussion;
+    use GetsPosts;
 
     /**
      * The post repository.
@@ -37,14 +37,19 @@ class IndexAction extends BaseAction
     protected function run(ApiParams $params)
     {
         $postIds = (array) $params->get('ids');
-        $include = ['user', 'user.groups', 'editUser', 'hideUser'];
+        $include = ['user', 'user.groups', 'editUser', 'hideUser', 'discussion'];
         $user = $this->actor->getUser();
 
         if (count($postIds)) {
             $posts = $this->posts->findByIds($postIds, $user);
         } else {
-            $discussionId = $params->get('discussions');
-            $posts = $this->getPostsForDiscussion($params, $discussionId, $user);
+            if ($discussionId = $params->get('discussions')) {
+                $where['discussion_id'] = $discussionId;
+            }
+            if ($userId = $params->get('users')) {
+                $where['user_id'] = $userId;
+            }
+            $posts = $this->getPosts($params, $where, $user);
         }
 
         if (! count($posts)) {
diff --git a/framework/core/src/Api/Serializers/ActivitySerializer.php b/framework/core/src/Api/Serializers/ActivitySerializer.php
index 65c1369f1..271e2153b 100644
--- a/framework/core/src/Api/Serializers/ActivitySerializer.php
+++ b/framework/core/src/Api/Serializers/ActivitySerializer.php
@@ -1,19 +1,65 @@
 <?php namespace Flarum\Api\Serializers;
 
 use Flarum\Core\Models\Activity;
-use Event;
 
-class ActivitySerializer extends BaseSerializer {
-	
-	public function serialize(Activity $activity)
-	{
-		$serialized = [
-			'id' => (int) $activity->id
-		];
+class ActivitySerializer extends BaseSerializer
+{
+    /**
+     * The resource type.
+     * @var string
+     */
+    protected $type = 'activity';
 
-		Event::fire('flarum.api.serialize.activity', [&$serialized]);
+    /**
+     * Serialize attributes of an Activity model for JSON output.
+     *
+     * @param Activity $activity The Activity model to serialize.
+     * @return array
+     */
+    protected function attributes(Activity $activity)
+    {
+        $attributes = [
+            'id'   => ((int) $activity->id) ?: str_random(5),
+            'type' => $activity->type,
+            'content' => json_encode($activity->data),
+            'time' => $activity->time->toRFC3339String()
+        ];
 
-		return $serialized;
-	}
+        return $this->attributesEvent($activity, $attributes);
+    }
 
+    /**
+     * Get a resource containing an activity's sender.
+     *
+     * @param Activity $activity
+     * @return Tobscure\JsonApi\Resource
+     */
+    public function linkUser(Activity $activity)
+    {
+        return (new UserBasicSerializer)->resource($activity->user_id);
+    }
+
+    /**
+     * Get a resource containing an activity's sender.
+     *
+     * @param Activity $activity
+     * @param array $relations
+     * @return Tobscure\JsonApi\Resource
+     */
+    public function includeSender(Activity $activity, $relations)
+    {
+        return (new UserBasicSerializer($relations))->resource($activity->sender);
+    }
+
+    /**
+     * Get a resource containing an activity's sender.
+     *
+     * @param Activity $activity
+     * @param array $relations
+     * @return Tobscure\JsonApi\Resource
+     */
+    public function includePost(Activity $activity, $relations)
+    {
+        return (new PostSerializer($relations))->resource($activity->post);
+    }
 }
diff --git a/framework/core/src/Api/Serializers/PostSerializer.php b/framework/core/src/Api/Serializers/PostSerializer.php
index d903a3726..ab84ecdb7 100644
--- a/framework/core/src/Api/Serializers/PostSerializer.php
+++ b/framework/core/src/Api/Serializers/PostSerializer.php
@@ -79,7 +79,7 @@ class PostSerializer extends PostBasicSerializer
      */
     public function includeDiscussion(Post $post, $relations = [])
     {
-        return (new DiscussionBasicSerializer($relations))->resource($post->discussion);
+        return (new DiscussionSerializer($relations))->resource($post->discussion);
     }
 
     /**
diff --git a/framework/core/src/Core/CoreServiceProvider.php b/framework/core/src/Core/CoreServiceProvider.php
index 8d507b67f..fb298a0b9 100644
--- a/framework/core/src/Core/CoreServiceProvider.php
+++ b/framework/core/src/Core/CoreServiceProvider.php
@@ -73,6 +73,14 @@ class CoreServiceProvider extends ServiceProvider
             'Flarum\Core\Repositories\UserRepositoryInterface',
             'Flarum\Core\Repositories\EloquentUserRepository'
         );
+        $this->app->bind(
+            'Flarum\Core\Repositories\ActivityRepositoryInterface',
+            'Flarum\Core\Repositories\EloquentActivityRepository'
+        );
+        $this->app->bind(
+            'Flarum\Core\Repositories\NotificationRepositoryInterface',
+            'Flarum\Core\Repositories\EloquentNotificationRepository'
+        );
     }
 
     public function registerGambits()
diff --git a/framework/core/src/Core/Models/Activity.php b/framework/core/src/Core/Models/Activity.php
index d053c81d5..e75860b71 100644
--- a/framework/core/src/Core/Models/Activity.php
+++ b/framework/core/src/Core/Models/Activity.php
@@ -1,36 +1,69 @@
-<?php namespace Flarum\Core\Activity;
+<?php namespace Flarum\Core\Models;
 
-use Flarum\Core\Entity;
-use Illuminate\Support\Str;
-use Auth;
+class Activity extends Model
+{
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'activity';
 
-class Activity extends Entity {
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['time'];
 
-	protected $table = 'activity';
+    /**
+     * Unserialize the data attribute.
+     *
+     * @param  string  $value
+     * @return string
+     */
+    public function getDataAttribute($value)
+    {
+        return json_decode($value);
+    }
 
-	public function getDates()
-	{
-		return ['time'];
-	}
+    /**
+     * Serialize the data attribute.
+     *
+     * @param  string  $value
+     */
+    public function setDataAttribute($value)
+    {
+        $this->attributes['data'] = json_encode($value);
+    }
 
-	public function fromUser()
-	{
-		return $this->belongsTo('Flarum\Core\Models\User', 'from_user_id');
-	}
+    /**
+     * Define the relationship with the activity's recipient.
+     *
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     */
+    public function user()
+    {
+        return $this->belongsTo('Flarum\Core\Models\User', 'user_id');
+    }
 
-	public function permission($permission)
-	{
-		return User::current()->can($permission, 'activity', $this);
-	}
-
-	public function editable()
-	{
-		return $this->permission('edit');
-	}
-
-	public function deletable()
-	{
-		return $this->permission('delete');
-	}
+    /**
+     * Define the relationship with the activity's sender.
+     *
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     */
+    public function sender()
+    {
+        return $this->belongsTo('Flarum\Core\Models\User', 'sender_id');
+    }
 
+    /**
+     * Define the relationship with the activity's sender.
+     *
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     */
+    public function post()
+    {
+        return $this->belongsTo('Flarum\Core\Models\Post', 'post_id');
+    }
 }
diff --git a/framework/core/src/Core/Repositories/ActivityRepositoryInterface.php b/framework/core/src/Core/Repositories/ActivityRepositoryInterface.php
new file mode 100644
index 000000000..7687732e4
--- /dev/null
+++ b/framework/core/src/Core/Repositories/ActivityRepositoryInterface.php
@@ -0,0 +1,8 @@
+<?php namespace Flarum\Core\Repositories;
+
+use Flarum\Core\Models\User;
+
+interface ActivityRepositoryInterface
+{
+    public function findByUser($userId, User $user, $count = null, $start = 0, $type = null);
+}
diff --git a/framework/core/src/Core/Repositories/EloquentActivityRepository.php b/framework/core/src/Core/Repositories/EloquentActivityRepository.php
new file mode 100644
index 000000000..e4eefd379
--- /dev/null
+++ b/framework/core/src/Core/Repositories/EloquentActivityRepository.php
@@ -0,0 +1,39 @@
+<?php namespace Flarum\Core\Repositories;
+
+use Flarum\Core\Models\Activity;
+use Flarum\Core\Models\Post;
+use Flarum\Core\Models\User;
+
+class EloquentActivityRepository implements ActivityRepositoryInterface
+{
+    public function findByUser($userId, User $viewer, $count = null, $start = 0, $type = null)
+    {
+        // This is all very rough and needs to be cleaned up
+
+        $null = \DB::raw('NULL');
+        $query = Activity::with('sender')->select('id', 'user_id', 'sender_id', 'type', 'data', 'time', \DB::raw('NULL as post_id'))->where('user_id', $userId);
+
+        if ($type) {
+            $query->where('type', $type);
+        }
+
+        $posts = Post::whereCan($viewer, 'view')->with('post', 'post.discussion', 'post.user', 'post.discussion.startUser', 'post.discussion.lastUser')->select(\DB::raw("CONCAT('post', id)"), 'user_id', $null, \DB::raw("'post'"), $null, 'time', 'id')->where('user_id', $userId);
+
+        if ($type === 'post') {
+            $posts->where('number', '>', 1);
+        } elseif ($type === 'discussion') {
+            $posts->where('number', 1);
+        }
+
+        if (!$type) {
+            $join = User::select(\DB::raw("CONCAT('join', id)"), 'id', 'id', \DB::raw("'join'"), $null, 'join_time', $null)->where('id', $userId);
+            $query->union($join->getQuery());
+        }
+
+        return $query->union($posts->getQuery())
+            ->orderBy('time', 'desc')
+            ->skip($start)
+            ->take($count)
+            ->get();
+    }
+}
diff --git a/framework/core/src/Core/Repositories/EloquentPostRepository.php b/framework/core/src/Core/Repositories/EloquentPostRepository.php
index 5366e2d3a..493c04796 100644
--- a/framework/core/src/Core/Repositories/EloquentPostRepository.php
+++ b/framework/core/src/Core/Repositories/EloquentPostRepository.php
@@ -24,10 +24,10 @@ class EloquentPostRepository implements PostRepositoryInterface
     }
 
     /**
-     * Find posts in a discussion, optionally making sure they are visible to
-     * a certain user, and/or using other criteria.
+     * Find posts that match certain conditions, optionally making sure they
+     * are visible to a certain user, and/or using other criteria.
      *
-     * @param  integer  $discussionId
+     * @param  array  $where
      * @param  \Flarum\Core\Models\User|null  $user
      * @param  string  $sort
      * @param  string  $order
@@ -35,9 +35,9 @@ class EloquentPostRepository implements PostRepositoryInterface
      * @param  integer  $start
      * @return \Illuminate\Database\Eloquent\Collection
      */
-    public function findByDiscussion($discussionId, User $user = null, $sort = 'time', $order = 'asc', $count = null, $start = 0)
+    public function findWhere($where = [], User $user = null, $sort = 'time', $order = 'asc', $count = null, $start = 0)
     {
-        $query = Post::where('discussion_id', $discussionId)
+        $query = Post::where($where)
             ->orderBy($sort, $order)
             ->skip($start)
             ->take($count);
diff --git a/framework/core/src/Core/Repositories/PostRepositoryInterface.php b/framework/core/src/Core/Repositories/PostRepositoryInterface.php
index 8796e82ec..c37b68a49 100644
--- a/framework/core/src/Core/Repositories/PostRepositoryInterface.php
+++ b/framework/core/src/Core/Repositories/PostRepositoryInterface.php
@@ -17,10 +17,10 @@ interface PostRepositoryInterface
     public function findOrFail($id, User $user = null);
 
     /**
-     * Find posts in a discussion, optionally making sure they are visible to
-     * a certain user, and/or using other criteria.
+     * Find posts that match certain conditions, optionally making sure they
+     * are visible to a certain user, and/or using other criteria.
      *
-     * @param  integer  $discussionId
+     * @param  array  $where
      * @param  \Flarum\Core\Models\User|null  $user
      * @param  string  $sort
      * @param  string  $order
@@ -28,7 +28,7 @@ interface PostRepositoryInterface
      * @param  integer  $start
      * @return \Illuminate\Database\Eloquent\Collection
      */
-    public function findByDiscussion($discussionId, User $user = null, $sort = 'time', $order = 'asc', $count = null, $start = 0);
+    public function findWhere($where = [], User $user = null, $sort = 'time', $order = 'asc', $count = null, $start = 0);
 
     /**
      * Find posts by their IDs, optionally making sure they are visible to a