From 500c279fb331a1a3b4188135de396e44ec6d11b8 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 20 May 2015 12:30:27 +0930 Subject: [PATCH] New user activity feed API. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Originally the user activity feed was implemented using UNIONs. I was looking at make an API to add activity “sources”, or extra UNION queries (select from posts, mentions, etc.) but quickly realised that this is too slow and there’s no way to make it scale. So I’ve implemented an API which is very similar to how notifications work (see previous commit). The `activity` table is an aggregation of stuff that happens, and it’s kept in sync by an ActivitySyncer which is used whenever a post it created/edited/deleted, a user is mentioned/unmentioned, etc. Again, the API is very simple (see Core\Activity\PostedActivity + Core\Handlers\Events\UserActivitySyncer) --- .../{join-activity.js => joined-activity.js} | 2 +- .../{post-activity.js => posted-activity.js} | 8 +- .../js/forum/src/initializers/components.js | 15 ++-- .../core/js/forum/src/initializers/routes.js | 4 +- framework/core/js/lib/models/activity.js | 3 +- ...015_02_24_000000_create_activity_table.php | 3 +- .../src/Api/Actions/Activity/IndexAction.php | 12 ++- .../Api/Serializers/ActivitySerializer.php | 19 ++++- .../src/Core/Activity/ActivityAbstract.php | 5 ++ .../src/Core/Activity/ActivityInterface.php | 32 ++++++++ .../core/src/Core/Activity/ActivitySyncer.php | 53 +++++++++++++ .../core/src/Core/Activity/JoinedActivity.php | 33 ++++++++ .../core/src/Core/Activity/PostedActivity.php | 33 ++++++++ .../Activity/StartedDiscussionActivity.php | 9 +++ .../core/src/Core/CoreServiceProvider.php | 8 ++ .../Handlers/Events/UserActivitySyncer.php | 76 +++++++++++++++++++ framework/core/src/Core/Models/Activity.php | 33 +++++--- .../Types/AlertableNotification.php | 32 -------- .../EloquentActivityRepository.php | 33 ++------ framework/core/src/Extend/ActivityType.php | 27 +++++++ 20 files changed, 343 insertions(+), 97 deletions(-) rename framework/core/js/forum/src/components/{join-activity.js => joined-activity.js} (88%) rename framework/core/js/forum/src/components/{post-activity.js => posted-activity.js} (80%) create mode 100644 framework/core/src/Core/Activity/ActivityAbstract.php create mode 100644 framework/core/src/Core/Activity/ActivityInterface.php create mode 100644 framework/core/src/Core/Activity/ActivitySyncer.php create mode 100644 framework/core/src/Core/Activity/JoinedActivity.php create mode 100644 framework/core/src/Core/Activity/PostedActivity.php create mode 100644 framework/core/src/Core/Activity/StartedDiscussionActivity.php create mode 100755 framework/core/src/Core/Handlers/Events/UserActivitySyncer.php delete mode 100644 framework/core/src/Core/Notifications/Types/AlertableNotification.php create mode 100644 framework/core/src/Extend/ActivityType.php diff --git a/framework/core/js/forum/src/components/join-activity.js b/framework/core/js/forum/src/components/joined-activity.js similarity index 88% rename from framework/core/js/forum/src/components/join-activity.js rename to framework/core/js/forum/src/components/joined-activity.js index bce3ee5bf..d5bef94cf 100644 --- a/framework/core/js/forum/src/components/join-activity.js +++ b/framework/core/js/forum/src/components/joined-activity.js @@ -2,7 +2,7 @@ import Component from 'flarum/component'; import humanTime from 'flarum/helpers/human-time'; import avatar from 'flarum/helpers/avatar'; -export default class JoinActivity extends Component { +export default class JoinedActivity extends Component { view() { var activity = this.props.activity; var user = activity.user(); diff --git a/framework/core/js/forum/src/components/post-activity.js b/framework/core/js/forum/src/components/posted-activity.js similarity index 80% rename from framework/core/js/forum/src/components/post-activity.js rename to framework/core/js/forum/src/components/posted-activity.js index 8cbf66263..9b62d620a 100644 --- a/framework/core/js/forum/src/components/post-activity.js +++ b/framework/core/js/forum/src/components/posted-activity.js @@ -4,11 +4,11 @@ import avatar from 'flarum/helpers/avatar'; import listItems from 'flarum/helpers/list-items'; import ItemList from 'flarum/utils/item-list'; -export default class PostActivity extends Component { +export default class PostedActivity extends Component { view() { var activity = this.props.activity; var user = activity.user(); - var post = activity.post(); + var post = activity.subject(); var discussion = post.discussion(); return m('div', [ @@ -23,7 +23,7 @@ export default class PostActivity extends Component { near: post.number() }), config: m.route}, [ m('ul.list-inline', listItems(this.headerItems().toArray())), - m('div.body', m.trust(post.contentHtml())) + m('div.body', m.trust(post.excerpt())) ]) ]); } @@ -31,7 +31,7 @@ export default class PostActivity extends Component { headerItems() { var items = new ItemList(); - items.add('title', m('h3.title', this.props.activity.post().discussion().title())); + items.add('title', m('h3.title', this.props.activity.subject().discussion().title())); return items; } diff --git a/framework/core/js/forum/src/initializers/components.js b/framework/core/js/forum/src/initializers/components.js index 92cb63ab8..f71a69909 100644 --- a/framework/core/js/forum/src/initializers/components.js +++ b/framework/core/js/forum/src/initializers/components.js @@ -1,21 +1,22 @@ import CommentPost from 'flarum/components/comment-post'; import DiscussionRenamedPost from 'flarum/components/discussion-renamed-post'; -import PostActivity from 'flarum/components/post-activity'; -import JoinActivity from 'flarum/components/join-activity'; +import PostedActivity from 'flarum/components/posted-activity'; +import JoinedActivity from 'flarum/components/joined-activity'; import DiscussionRenamedNotification from 'flarum/components/discussion-renamed-notification'; export default function(app) { app.postComponentRegistry = { - comment: CommentPost, - discussionRenamed: DiscussionRenamedPost + 'comment': CommentPost, + 'discussionRenamed': DiscussionRenamedPost }; app.activityComponentRegistry = { - post: PostActivity, - join: JoinActivity + 'posted': PostedActivity, + 'startedDiscussion': PostedActivity, + 'joined': JoinedActivity }; app.notificationComponentRegistry = { - discussionRenamed: DiscussionRenamedNotification + 'discussionRenamed': DiscussionRenamedNotification }; } diff --git a/framework/core/js/forum/src/initializers/routes.js b/framework/core/js/forum/src/initializers/routes.js index 97c8aa031..0ad18b168 100644 --- a/framework/core/js/forum/src/initializers/routes.js +++ b/framework/core/js/forum/src/initializers/routes.js @@ -13,8 +13,8 @@ export default function(app) { 'user': ['/u/:username', ActivityPage.component()], 'user.activity': ['/u/:username', ActivityPage.component()], - 'user.discussions': ['/u/:username/discussions', ActivityPage.component({filter: 'discussion'})], - 'user.posts': ['/u/:username/posts', ActivityPage.component({filter: 'post'})], + 'user.discussions': ['/u/:username/discussions', ActivityPage.component({filter: 'startedDiscussion'})], + 'user.posts': ['/u/:username/posts', ActivityPage.component({filter: 'posted'})], 'settings': ['/settings', SettingsPage.component()] }; diff --git a/framework/core/js/lib/models/activity.js b/framework/core/js/lib/models/activity.js index 0d620b3bb..e2ddab39b 100644 --- a/framework/core/js/lib/models/activity.js +++ b/framework/core/js/lib/models/activity.js @@ -8,7 +8,6 @@ Activity.prototype.content = Model.prop('content'); Activity.prototype.time = Model.prop('time', Model.date); Activity.prototype.user = Model.one('user'); -Activity.prototype.sender = Model.one('sender'); -Activity.prototype.post = Model.one('post'); +Activity.prototype.subject = Model.one('subject'); export default Activity; 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 6b5e96a6d..a0a601641 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 @@ -14,11 +14,10 @@ class CreateActivityTable extends Migration public function up() { Schema::create('activity', function (Blueprint $table) { - $table->increments('id'); $table->integer('user_id')->unsigned(); - $table->integer('sender_id')->unsigned()->nullable(); $table->string('type', 100); + $table->integer('subject_id')->unsigned()->nullable(); $table->binary('data')->nullable(); $table->dateTime('time'); }); diff --git a/framework/core/src/Api/Actions/Activity/IndexAction.php b/framework/core/src/Api/Actions/Activity/IndexAction.php index 42c89331a..dc9a42a70 100644 --- a/framework/core/src/Api/Actions/Activity/IndexAction.php +++ b/framework/core/src/Api/Actions/Activity/IndexAction.php @@ -32,12 +32,9 @@ class IndexAction extends SerializeCollectionAction * @var array */ public static $include = [ - 'sender' => true, - 'post' => true, - 'post.user' => true, - 'post.discussion' => true, - 'post.discussion.startUser' => true, - 'post.discussion.lastUser' => true + 'subject' => true, + 'subject.user' => true, + 'subject.discussion' => true ]; /** @@ -73,6 +70,7 @@ class IndexAction extends SerializeCollectionAction $user = $this->users->findOrFail($request->get('users'), $actor); - return $this->activity->findByUser($user->id, $actor, $request->limit, $request->offset, $request->get('type')); + return $this->activity->findByUser($user->id, $actor, $request->limit, $request->offset, $request->get('type')) + ->load($request->include); } } diff --git a/framework/core/src/Api/Serializers/ActivitySerializer.php b/framework/core/src/Api/Serializers/ActivitySerializer.php index 2348598e4..4dd10b8c9 100644 --- a/framework/core/src/Api/Serializers/ActivitySerializer.php +++ b/framework/core/src/Api/Serializers/ActivitySerializer.php @@ -9,6 +9,17 @@ class ActivitySerializer extends BaseSerializer */ protected $type = 'activity'; + /** + * A map of activity types (key) to the serializer that should be used to + * output the activity's subject (value). + * + * @var array + */ + public static $subjects = [ + 'posted' => 'Flarum\Api\Serializers\PostBasicSerializer', + 'joined' => 'Flarum\Api\Serializers\UserBasicSerializer' + ]; + /** * Serialize attributes of an Activity model for JSON output. * @@ -18,9 +29,7 @@ class ActivitySerializer extends BaseSerializer protected function attributes($activity) { $attributes = [ - 'id' => ((int) $activity->id) ?: str_random(5), 'contentType' => $activity->type, - 'content' => json_encode($activity->data), 'time' => $activity->time->toRFC3339String() ]; @@ -37,8 +46,10 @@ class ActivitySerializer extends BaseSerializer return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } - public function post() + public function subject() { - return $this->hasOne('Flarum\Api\Serializers\PostSerializer'); + return $this->hasOne(function ($activity) { + return static::$subjects[$activity->type]; + }); } } diff --git a/framework/core/src/Core/Activity/ActivityAbstract.php b/framework/core/src/Core/Activity/ActivityAbstract.php new file mode 100644 index 000000000..d39e67b42 --- /dev/null +++ b/framework/core/src/Core/Activity/ActivityAbstract.php @@ -0,0 +1,5 @@ +activity = $activity; + } + + /** + * Sync a piece of activity so that it is present for the specified users, + * and not present for anyone else. + * + * @param \Flarum\Core\Activity\ActivityInterface $activity + * @param \Flarum\Core\Models\User[] $users + * @return void + */ + public function sync(ActivityInterface $activity, array $users) + { + Activity::unguard(); + + $attributes = [ + 'type' => $activity::getType(), + 'subject_id' => $activity->getSubject()->id, + 'time' => $activity->getTime() + ]; + + $toDelete = Activity::where($attributes)->get(); + $toInsert = []; + + foreach ($users as $user) { + $existing = $toDelete->where('user_id', $user->id)->first(); + + if ($k = $toDelete->search($existing)) { + $toDelete->pull($k); + } else { + $toInsert[] = $attributes + ['user_id' => $user->id]; + } + } + + if (count($toDelete)) { + Activity::whereIn('id', $toDelete->lists('id'))->delete(); + } + if (count($toInsert)) { + Activity::insert($toInsert); + } + } +} diff --git a/framework/core/src/Core/Activity/JoinedActivity.php b/framework/core/src/Core/Activity/JoinedActivity.php new file mode 100644 index 000000000..38b6bf4ce --- /dev/null +++ b/framework/core/src/Core/Activity/JoinedActivity.php @@ -0,0 +1,33 @@ +user = $user; + } + + public function getSubject() + { + return $this->user; + } + + public function getTime() + { + return $this->user->join_time; + } + + public static function getType() + { + return 'joined'; + } + + public static function getSubjectModel() + { + return 'Flarum\Core\Models\User'; + } +} diff --git a/framework/core/src/Core/Activity/PostedActivity.php b/framework/core/src/Core/Activity/PostedActivity.php new file mode 100644 index 000000000..8c306c663 --- /dev/null +++ b/framework/core/src/Core/Activity/PostedActivity.php @@ -0,0 +1,33 @@ +post = $post; + } + + public function getSubject() + { + return $this->post; + } + + public function getTime() + { + return $this->post->time; + } + + public static function getType() + { + return 'posted'; + } + + public static function getSubjectModel() + { + return 'Flarum\Core\Models\Post'; + } +} diff --git a/framework/core/src/Core/Activity/StartedDiscussionActivity.php b/framework/core/src/Core/Activity/StartedDiscussionActivity.php new file mode 100644 index 000000000..9b7b3a550 --- /dev/null +++ b/framework/core/src/Core/Activity/StartedDiscussionActivity.php @@ -0,0 +1,9 @@ +subscribe('Flarum\Core\Handlers\Events\DiscussionRenamedNotifier'); + $events->subscribe('Flarum\Core\Handlers\Events\UserActivitySyncer'); + $this->extend( (new NotificationType('Flarum\Core\Notifications\DiscussionRenamedNotification', 'Flarum\Api\Serializers\DiscussionBasicSerializer')) ->enableByDefault('alert'), + + (new ActivityType('Flarum\Core\Activity\PostedActivity', 'Flarum\Api\Serializers\PostBasicSerializer')), + (new ActivityType('Flarum\Core\Activity\StartedDiscussionActivity', 'Flarum\Api\Serializers\PostBasicSerializer')), + + (new ActivityType('Flarum\Core\Activity\JoinedActivity', 'Flarum\Api\Serializers\UserBasicSerializer')) ); } diff --git a/framework/core/src/Core/Handlers/Events/UserActivitySyncer.php b/framework/core/src/Core/Handlers/Events/UserActivitySyncer.php new file mode 100755 index 000000000..369222b90 --- /dev/null +++ b/framework/core/src/Core/Handlers/Events/UserActivitySyncer.php @@ -0,0 +1,76 @@ +activity = $activity; + } + + public function subscribe(Dispatcher $events) + { + $events->listen('Flarum\Core\Events\PostWasPosted', __CLASS__.'@whenPostWasPosted'); + $events->listen('Flarum\Core\Events\PostWasHidden', __CLASS__.'@whenPostWasHidden'); + $events->listen('Flarum\Core\Events\PostWasRestored', __CLASS__.'@whenPostWasRestored'); + $events->listen('Flarum\Core\Events\PostWasDeleted', __CLASS__.'@whenPostWasDeleted'); + $events->listen('Flarum\Core\Events\UserWasRegistered', __CLASS__.'@whenUserWasRegistered'); + } + + public function whenPostWasPosted(PostWasPosted $event) + { + $this->postBecameVisible($event->post); + } + + public function whenPostWasHidden(PostWasHidden $event) + { + $this->postBecameInvisible($event->post); + } + + public function whenPostWasRestored(PostWasRestored $event) + { + $this->postBecameVisible($event->post); + } + + public function whenPostWasDeleted(PostWasDeleted $event) + { + $this->postBecameInvisible($event->post); + } + + public function whenUserWasRegistered(UserWasRegistered $event) + { + $this->activity->sync(new JoinedActivity($event->user), [$event->user]); + } + + protected function postBecameVisible(Post $post) + { + $activity = $this->postedActivity($post); + + $this->activity->sync($activity, [$post->user]); + } + + protected function postBecameInvisible(Post $post) + { + $activity = $this->postedActivity($post); + + $this->activity->sync($activity, []); + } + + protected function postedActivity(Post $post) + { + return $post->number === 1 ? new StartedDiscussionActivity($post) : new PostedActivity($post); + } +} diff --git a/framework/core/src/Core/Models/Activity.php b/framework/core/src/Core/Models/Activity.php index e75860b71..d12c6434e 100644 --- a/framework/core/src/Core/Models/Activity.php +++ b/framework/core/src/Core/Models/Activity.php @@ -16,6 +16,13 @@ class Activity extends Model */ protected $dates = ['time']; + /** + * + * + * @var array + */ + protected static $subjects = []; + /** * Unserialize the data attribute. * @@ -47,23 +54,27 @@ class Activity extends Model return $this->belongsTo('Flarum\Core\Models\User', 'user_id'); } - /** - * Define the relationship with the activity's sender. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function sender() + public function subject() { - return $this->belongsTo('Flarum\Core\Models\User', 'sender_id'); + return $this->mappedMorphTo(static::$subjects, 'subject', 'type', 'subject_id'); + } + + public static function getTypes() + { + return static::$subjects; } /** - * Define the relationship with the activity's sender. + * Register a notification type. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @param string $type + * @param string $class + * @return void */ - public function post() + public static function registerType($class) { - return $this->belongsTo('Flarum\Core\Models\Post', 'post_id'); + if ($subject = $class::getSubjectModel()) { + static::$subjects[$class::getType()] = $subject; + } } } diff --git a/framework/core/src/Core/Notifications/Types/AlertableNotification.php b/framework/core/src/Core/Notifications/Types/AlertableNotification.php deleted file mode 100644 index 25130c430..000000000 --- a/framework/core/src/Core/Notifications/Types/AlertableNotification.php +++ /dev/null @@ -1,32 +0,0 @@ -whereIn('type', array_keys(Activity::getTypes())) + ->orderBy('time', 'desc') + ->skip($offset) + ->take($limit); - $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) { + if ($type !== null) { $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)->where('type', 'comment')->whereNull('hide_time'); - - 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(); + return $query->get(); } } diff --git a/framework/core/src/Extend/ActivityType.php b/framework/core/src/Extend/ActivityType.php new file mode 100644 index 000000000..92a86c079 --- /dev/null +++ b/framework/core/src/Extend/ActivityType.php @@ -0,0 +1,27 @@ +class = $class; + $this->serializer = $serializer; + } + + public function extend(Application $app) + { + $class = $this->class; + + Activity::registerType($class); + + ActivitySerializer::$subjects[$class::getType()] = $this->serializer; + } +}