New user activity feed API.

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)
This commit is contained in:
Toby Zerner 2015-05-20 12:30:27 +09:30
parent 8edb684ea9
commit 500c279fb3
20 changed files with 343 additions and 97 deletions

View File

@ -2,7 +2,7 @@ import Component from 'flarum/component';
import humanTime from 'flarum/helpers/human-time'; import humanTime from 'flarum/helpers/human-time';
import avatar from 'flarum/helpers/avatar'; import avatar from 'flarum/helpers/avatar';
export default class JoinActivity extends Component { export default class JoinedActivity extends Component {
view() { view() {
var activity = this.props.activity; var activity = this.props.activity;
var user = activity.user(); var user = activity.user();

View File

@ -4,11 +4,11 @@ import avatar from 'flarum/helpers/avatar';
import listItems from 'flarum/helpers/list-items'; import listItems from 'flarum/helpers/list-items';
import ItemList from 'flarum/utils/item-list'; import ItemList from 'flarum/utils/item-list';
export default class PostActivity extends Component { export default class PostedActivity extends Component {
view() { view() {
var activity = this.props.activity; var activity = this.props.activity;
var user = activity.user(); var user = activity.user();
var post = activity.post(); var post = activity.subject();
var discussion = post.discussion(); var discussion = post.discussion();
return m('div', [ return m('div', [
@ -23,7 +23,7 @@ export default class PostActivity extends Component {
near: post.number() near: post.number()
}), config: m.route}, [ }), config: m.route}, [
m('ul.list-inline', listItems(this.headerItems().toArray())), 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() { headerItems() {
var items = new ItemList(); 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; return items;
} }

View File

@ -1,21 +1,22 @@
import CommentPost from 'flarum/components/comment-post'; import CommentPost from 'flarum/components/comment-post';
import DiscussionRenamedPost from 'flarum/components/discussion-renamed-post'; import DiscussionRenamedPost from 'flarum/components/discussion-renamed-post';
import PostActivity from 'flarum/components/post-activity'; import PostedActivity from 'flarum/components/posted-activity';
import JoinActivity from 'flarum/components/join-activity'; import JoinedActivity from 'flarum/components/joined-activity';
import DiscussionRenamedNotification from 'flarum/components/discussion-renamed-notification'; import DiscussionRenamedNotification from 'flarum/components/discussion-renamed-notification';
export default function(app) { export default function(app) {
app.postComponentRegistry = { app.postComponentRegistry = {
comment: CommentPost, 'comment': CommentPost,
discussionRenamed: DiscussionRenamedPost 'discussionRenamed': DiscussionRenamedPost
}; };
app.activityComponentRegistry = { app.activityComponentRegistry = {
post: PostActivity, 'posted': PostedActivity,
join: JoinActivity 'startedDiscussion': PostedActivity,
'joined': JoinedActivity
}; };
app.notificationComponentRegistry = { app.notificationComponentRegistry = {
discussionRenamed: DiscussionRenamedNotification 'discussionRenamed': DiscussionRenamedNotification
}; };
} }

View File

@ -13,8 +13,8 @@ export default function(app) {
'user': ['/u/:username', ActivityPage.component()], 'user': ['/u/:username', ActivityPage.component()],
'user.activity': ['/u/:username', ActivityPage.component()], 'user.activity': ['/u/:username', ActivityPage.component()],
'user.discussions': ['/u/:username/discussions', ActivityPage.component({filter: 'discussion'})], 'user.discussions': ['/u/:username/discussions', ActivityPage.component({filter: 'startedDiscussion'})],
'user.posts': ['/u/:username/posts', ActivityPage.component({filter: 'post'})], 'user.posts': ['/u/:username/posts', ActivityPage.component({filter: 'posted'})],
'settings': ['/settings', SettingsPage.component()] 'settings': ['/settings', SettingsPage.component()]
}; };

View File

@ -8,7 +8,6 @@ Activity.prototype.content = Model.prop('content');
Activity.prototype.time = Model.prop('time', Model.date); Activity.prototype.time = Model.prop('time', Model.date);
Activity.prototype.user = Model.one('user'); Activity.prototype.user = Model.one('user');
Activity.prototype.sender = Model.one('sender'); Activity.prototype.subject = Model.one('subject');
Activity.prototype.post = Model.one('post');
export default Activity; export default Activity;

View File

@ -14,11 +14,10 @@ class CreateActivityTable extends Migration
public function up() public function up()
{ {
Schema::create('activity', function (Blueprint $table) { Schema::create('activity', function (Blueprint $table) {
$table->increments('id'); $table->increments('id');
$table->integer('user_id')->unsigned(); $table->integer('user_id')->unsigned();
$table->integer('sender_id')->unsigned()->nullable();
$table->string('type', 100); $table->string('type', 100);
$table->integer('subject_id')->unsigned()->nullable();
$table->binary('data')->nullable(); $table->binary('data')->nullable();
$table->dateTime('time'); $table->dateTime('time');
}); });

View File

@ -32,12 +32,9 @@ class IndexAction extends SerializeCollectionAction
* @var array * @var array
*/ */
public static $include = [ public static $include = [
'sender' => true, 'subject' => true,
'post' => true, 'subject.user' => true,
'post.user' => true, 'subject.discussion' => true
'post.discussion' => true,
'post.discussion.startUser' => true,
'post.discussion.lastUser' => true
]; ];
/** /**
@ -73,6 +70,7 @@ class IndexAction extends SerializeCollectionAction
$user = $this->users->findOrFail($request->get('users'), $actor); $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);
} }
} }

View File

@ -9,6 +9,17 @@ class ActivitySerializer extends BaseSerializer
*/ */
protected $type = 'activity'; 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. * Serialize attributes of an Activity model for JSON output.
* *
@ -18,9 +29,7 @@ class ActivitySerializer extends BaseSerializer
protected function attributes($activity) protected function attributes($activity)
{ {
$attributes = [ $attributes = [
'id' => ((int) $activity->id) ?: str_random(5),
'contentType' => $activity->type, 'contentType' => $activity->type,
'content' => json_encode($activity->data),
'time' => $activity->time->toRFC3339String() 'time' => $activity->time->toRFC3339String()
]; ];
@ -37,8 +46,10 @@ class ActivitySerializer extends BaseSerializer
return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); 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];
});
} }
} }

View File

@ -0,0 +1,5 @@
<?php namespace Flarum\Core\Activity;
abstract class ActivityAbstract implements ActivityInterface
{
}

View File

@ -0,0 +1,32 @@
<?php namespace Flarum\Core\Activity;
interface ActivityInterface
{
/**
* Get the model that is the subject of this activity.
*
* @return \Flarum\Core\Models\Model
*/
public function getSubject();
/**
* Get the time at which the activity occurred.
*
* @return mixed
*/
public function getTime();
/**
* Get the serialized type of this activity.
*
* @return string
*/
public static function getType();
/**
* Get the name of the model class for the subject of this activity.
*
* @return string
*/
public static function getSubjectModel();
}

View File

@ -0,0 +1,53 @@
<?php namespace Flarum\Core\Activity;
use Flarum\Core\Repositories\ActivityRepositoryInterface;
use Flarum\Core\Models\Activity;
class ActivitySyncer
{
protected $activity;
public function __construct(ActivityRepositoryInterface $activity)
{
$this->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);
}
}
}

View File

@ -0,0 +1,33 @@
<?php namespace Flarum\Core\Activity;
use Flarum\Core\Models\User;
class JoinedActivity extends ActivityAbstract
{
protected $user;
public function __construct(User $user)
{
$this->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';
}
}

View File

@ -0,0 +1,33 @@
<?php namespace Flarum\Core\Activity;
use Flarum\Core\Models\Post;
class PostedActivity extends ActivityAbstract
{
protected $post;
public function __construct(Post $post)
{
$this->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';
}
}

View File

@ -0,0 +1,9 @@
<?php namespace Flarum\Core\Activity;
class StartedDiscussionActivity extends PostedActivity
{
public static function getType()
{
return 'startedDiscussion';
}
}

View File

@ -16,6 +16,7 @@ use League\Flysystem\Adapter\Local;
use Flarum\Core\Events\RegisterDiscussionGambits; use Flarum\Core\Events\RegisterDiscussionGambits;
use Flarum\Core\Events\RegisterUserGambits; use Flarum\Core\Events\RegisterUserGambits;
use Flarum\Extend\Permission; use Flarum\Extend\Permission;
use Flarum\Extend\ActivityType;
use Flarum\Extend\NotificationType; use Flarum\Extend\NotificationType;
class CoreServiceProvider extends ServiceProvider class CoreServiceProvider extends ServiceProvider
@ -46,9 +47,16 @@ class CoreServiceProvider extends ServiceProvider
}); });
$events->subscribe('Flarum\Core\Handlers\Events\DiscussionRenamedNotifier'); $events->subscribe('Flarum\Core\Handlers\Events\DiscussionRenamedNotifier');
$events->subscribe('Flarum\Core\Handlers\Events\UserActivitySyncer');
$this->extend( $this->extend(
(new NotificationType('Flarum\Core\Notifications\DiscussionRenamedNotification', 'Flarum\Api\Serializers\DiscussionBasicSerializer')) (new NotificationType('Flarum\Core\Notifications\DiscussionRenamedNotification', 'Flarum\Api\Serializers\DiscussionBasicSerializer'))
->enableByDefault('alert'), ->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'))
); );
} }

View File

@ -0,0 +1,76 @@
<?php namespace Flarum\Core\Handlers\Events;
use Flarum\Core\Activity\ActivitySyncer;
use Flarum\Core\Activity\PostedActivity;
use Flarum\Core\Activity\StartedDiscussionActivity;
use Flarum\Core\Activity\JoinedActivity;
use Flarum\Core\Events\PostWasPosted;
use Flarum\Core\Events\PostWasDeleted;
use Flarum\Core\Events\PostWasHidden;
use Flarum\Core\Events\PostWasRestored;
use Flarum\Core\Events\UserWasRegistered;
use Flarum\Core\Models\Post;
use Illuminate\Contracts\Events\Dispatcher;
class UserActivitySyncer
{
protected $activity;
public function __construct(ActivitySyncer $activity)
{
$this->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);
}
}

View File

@ -16,6 +16,13 @@ class Activity extends Model
*/ */
protected $dates = ['time']; protected $dates = ['time'];
/**
*
*
* @var array
*/
protected static $subjects = [];
/** /**
* Unserialize the data attribute. * Unserialize the data attribute.
* *
@ -47,23 +54,27 @@ class Activity extends Model
return $this->belongsTo('Flarum\Core\Models\User', 'user_id'); return $this->belongsTo('Flarum\Core\Models\User', 'user_id');
} }
/** public function subject()
* 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'); 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;
}
} }
} }

View File

@ -1,32 +0,0 @@
<?php namespace Flarum\Core\Notifications\Types;
interface AlertableNotification
{
/**
* Get the data to be stored in the alert.
*
* @return array
*/
public function getAlertData();
/**
* Get the user that sent the notification.
*
* @return \Flarum\Core\Models\User|null
*/
public function getSender();
/**
* Get the model that the notification is about.
*
* @return \Flarum\Core\Models\Model
*/
public function getSubject();
/**
* Get the class name of this notification type's subject model.
*
* @return string
*/
public static function getSubjectModel();
}

View File

@ -1,39 +1,22 @@
<?php namespace Flarum\Core\Repositories; <?php namespace Flarum\Core\Repositories;
use Flarum\Core\Models\Activity; use Flarum\Core\Models\Activity;
use Flarum\Core\Models\Post;
use Flarum\Core\Models\User; use Flarum\Core\Models\User;
class EloquentActivityRepository implements ActivityRepositoryInterface class EloquentActivityRepository implements ActivityRepositoryInterface
{ {
public function findByUser($userId, User $viewer, $count = null, $start = 0, $type = null) public function findByUser($userId, User $viewer, $limit = null, $offset = 0, $type = null)
{ {
// This is all very rough and needs to be cleaned up $query = Activity::where('user_id', $userId)
->whereIn('type', array_keys(Activity::getTypes()))
->orderBy('time', 'desc')
->skip($offset)
->take($limit);
$null = \DB::raw('NULL'); if ($type !== 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); $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'); return $query->get();
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();
} }
} }

View File

@ -0,0 +1,27 @@
<?php namespace Flarum\Extend;
use Illuminate\Foundation\Application;
use Flarum\Core\Models\Activity;
use Flarum\Api\Serializers\ActivitySerializer;
class ActivityType implements ExtenderInterface
{
protected $class;
protected $serializer;
public function __construct($class, $serializer)
{
$this->class = $class;
$this->serializer = $serializer;
}
public function extend(Application $app)
{
$class = $this->class;
Activity::registerType($class);
ActivitySerializer::$subjects[$class::getType()] = $this->serializer;
}
}