More powerful/extensible notifications

- Notifications can be delivered in multiple ways (alert, email)
- Different notification types can implement interfaces to allow
themselves to be delivered in these various ways
- User preferences for each notification type/method combination are
automatically registered
This commit is contained in:
Toby Zerner 2015-03-28 15:43:31 +10:30
parent 49c3fa09e6
commit bc9be30a02
15 changed files with 292 additions and 43 deletions

View File

@ -11,7 +11,6 @@ use Flarum\Core\Models\Model;
use Flarum\Core\Models\Forum; use Flarum\Core\Models\Forum;
use Flarum\Core\Models\User; use Flarum\Core\Models\User;
use Flarum\Core\Models\Discussion; use Flarum\Core\Models\Discussion;
use Flarum\Core\Models\Notification;
use Flarum\Core\Search\GambitManager; use Flarum\Core\Search\GambitManager;
use League\Flysystem\Adapter\Local; use League\Flysystem\Adapter\Local;
@ -28,7 +27,6 @@ class CoreServiceProvider extends ServiceProvider
$this->registerEventHandlers($events); $this->registerEventHandlers($events);
$this->registerPostTypes(); $this->registerPostTypes();
$this->registerNotificationTypes();
$this->registerPermissions(); $this->registerPermissions();
$this->registerGambits(); $this->registerGambits();
$this->setupModels(); $this->setupModels();
@ -47,6 +45,8 @@ class CoreServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
$this->app->register('Flarum\Core\Notifications\NotificationServiceProvider');
// Register a singleton entity that represents this forum. This entity // Register a singleton entity that represents this forum. This entity
// will be used to check for global forum permissions (like viewing the // will be used to check for global forum permissions (like viewing the
// forum, registering, and starting discussions.) // forum, registering, and starting discussions.)
@ -81,14 +81,6 @@ class CoreServiceProvider extends ServiceProvider
'Flarum\Core\Repositories\ActivityRepositoryInterface', 'Flarum\Core\Repositories\ActivityRepositoryInterface',
'Flarum\Core\Repositories\EloquentActivityRepository' 'Flarum\Core\Repositories\EloquentActivityRepository'
); );
$this->app->bind(
'Flarum\Core\Repositories\NotificationRepositoryInterface',
'Flarum\Core\Repositories\EloquentNotificationRepository'
);
$this->app->singleton('flarum.avatars.storage', function () {
return new Local(__DIR__.'/../../ember/public/avatars');
});
$this->app->when('Flarum\Core\Handlers\Commands\UploadAvatarCommandHandler') $this->app->when('Flarum\Core\Handlers\Commands\UploadAvatarCommandHandler')
->needs('League\Flysystem\FilesystemInterface') ->needs('League\Flysystem\FilesystemInterface')
@ -126,16 +118,10 @@ class CoreServiceProvider extends ServiceProvider
CommentPost::setFormatter($this->app['flarum.formatter']); CommentPost::setFormatter($this->app['flarum.formatter']);
} }
public function registerNotificationTypes()
{
Notification::addType('renamed', 'Flarum\Core\Models\Discussion');
}
public function registerEventHandlers($events) public function registerEventHandlers($events)
{ {
$events->subscribe('Flarum\Core\Handlers\Events\DiscussionMetadataUpdater'); $events->subscribe('Flarum\Core\Handlers\Events\DiscussionMetadataUpdater');
$events->subscribe('Flarum\Core\Handlers\Events\UserMetadataUpdater'); $events->subscribe('Flarum\Core\Handlers\Events\UserMetadataUpdater');
$events->subscribe('Flarum\Core\Handlers\Events\DiscussionRenamedNotifier');
$events->subscribe('Flarum\Core\Handlers\Events\EmailConfirmationMailer'); $events->subscribe('Flarum\Core\Handlers\Events\EmailConfirmationMailer');
} }

View File

@ -1,11 +1,17 @@
<?php namespace Flarum\Core\Handlers\Events; <?php namespace Flarum\Core\Handlers\Events;
use Flarum\Core\Events\DiscussionWasRenamed; use Flarum\Core\Events\DiscussionWasRenamed;
use Flarum\Core\Models\Notification;
use Flarum\Core\Models\DiscussionRenamedPost; use Flarum\Core\Models\DiscussionRenamedPost;
use Flarum\Core\Notifications\Types\DiscussionRenamedNotification;
use Flarum\Core\Notifications\Notifier;
class DiscussionRenamedNotifier class DiscussionRenamedNotifier
{ {
public function __construct(Notifier $notifier)
{
$this->notifier = $notifier;
}
/** /**
* Register the listeners for the subscriber. * Register the listeners for the subscriber.
* *
@ -44,17 +50,13 @@ class DiscussionRenamedNotifier
protected function sendNotification(DiscussionWasRenamed $event, DiscussionRenamedPost $post) protected function sendNotification(DiscussionWasRenamed $event, DiscussionRenamedPost $post)
{ {
$notification = new DiscussionRenamedNotification(
$notification = Notification::notify( $event->discussion->startUser,
$event->discussion->start_user_id, $event->user,
'renamed', $post,
$event->user->id, $event->oldTitle
$event->discussion->id,
['number' => $post->number, 'oldTitle' => $event->oldTitle]
); );
$notification->save(); $this->notifier->send($notification);
return $notification;
} }
} }

View File

@ -19,14 +19,13 @@ class Notification extends Model
protected $dates = ['time']; protected $dates = ['time'];
/** /**
* A map of notification types, as specified in the `type` column, to *
* their subject classes.
* *
* @var array * @var array
*/ */
protected static $types = []; protected static $subjects = [];
public static function notify($userId, $type, $senderId, $subjectId, $data) public static function alert($userId, $type, $senderId, $subjectId, $data)
{ {
$notification = new static; $notification = new static;
@ -95,41 +94,41 @@ class Notification extends Model
// If the type value is null it is probably safe to assume we're eager loading // If the type value is null it is probably safe to assume we're eager loading
// the relationship. When that is the case we will pass in a dummy query as // the relationship. When that is the case we will pass in a dummy query as
// there are multiple types in the morph and we can't use single queries. // there are multiple types in the morph and we can't use single queries.
if (is_null($type = $this->$typeColumn)) if (is_null($type = $this->$typeColumn)) {
{
return new MappedMorphTo( return new MappedMorphTo(
$this->newQuery(), $this, $idColumn, null, $typeColumn, $name, static::$types $this->newQuery(), $this, $idColumn, null, $typeColumn, $name, static::$subjects
); );
} }
// If we are not eager loading the relationship we will essentially treat this // If we are not eager loading the relationship we will essentially treat this
// as a belongs-to style relationship since morph-to extends that class and // as a belongs-to style relationship since morph-to extends that class and
// we will pass in the appropriate values so that it behaves as expected. // we will pass in the appropriate values so that it behaves as expected.
else else {
{ $class = static::$subjects[$type];
$class = static::$types[$type];
$instance = new $class; $instance = new $class;
return new MappedMorphTo( return new MappedMorphTo(
$instance->newQuery(), $this, $idColumn, $instance->getKeyName(), $typeColumn, $name, static::$types $instance->newQuery(), $this, $idColumn, $instance->getKeyName(), $typeColumn, $name, static::$subjects
); );
} }
} }
public static function getTypes() public static function getTypes()
{ {
return static::$types; return static::$subjects;
} }
/** /**
* Register a notification type and its subject class. * Register a notification type.
* *
* @param string $type * @param string $type
* @param string $class * @param string $class
* @return void * @return void
*/ */
public static function addType($type, $class) public static function registerType($class)
{ {
static::$types[$type] = $class; if ($subject = $class::getSubjectModel()) {
static::$subjects[$class::getType()] = $subject;
}
} }
} }

View File

@ -356,7 +356,7 @@ class User extends Model
$defaults[$k] = $v['default']; $defaults[$k] = $v['default'];
} }
return array_merge($defaults, (array) json_decode($value, true)); return array_merge($defaults, array_only((array) json_decode($value, true), array_keys(static::$preferences)));
} }
public function setPreferencesAttribute($value) public function setPreferencesAttribute($value)
@ -372,6 +372,16 @@ class User extends Model
]; ];
} }
public static function notificationPreferenceKey($type, $sender)
{
return 'notify_'.$type.'_'.$sender;
}
public function shouldNotify($type, $method)
{
return $this->preference(static::notificationPreferenceKey($type, $method));
}
public function preference($key, $default = null) public function preference($key, $default = null)
{ {
return array_get($this->preferences, $key, $default); return array_get($this->preferences, $key, $default);

View File

@ -0,0 +1,35 @@
<?php namespace Flarum\Core\Notifications;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\ServiceProvider;
use Flarum\Core\Models\User;
class NotificationServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application events.
*
* @return void
*/
public function boot(Dispatcher $events)
{
$notifier = app('Flarum\Core\Notifications\Notifier');
$notifier->registerMethod('alert', 'Flarum\Core\Notifications\Senders\NotificationAlerter');
$notifier->registerMethod('email', 'Flarum\Core\Notifications\Senders\NotificationEmailer');
$notifier->registerType('Flarum\Core\Notifications\Types\DiscussionRenamedNotification', ['alert' => true]);
$events->subscribe('Flarum\Core\Handlers\Events\DiscussionRenamedNotifier');
}
public function register()
{
$this->app->bind(
'Flarum\Core\Repositories\NotificationRepositoryInterface',
'Flarum\Core\Repositories\EloquentNotificationRepository'
);
$this->app->singleton('Flarum\Core\Notifications\Notifier');
}
}

View File

@ -0,0 +1,59 @@
<?php namespace Flarum\Core\Notifications;
use Flarum\Core\Notifications\Types\Notification;
use Flarum\Core\Models\Notification as NotificationModel;
use Flarum\Core\Models\User;
use Illuminate\Container\Container;
class Notifier
{
protected $methods = [];
protected $types = [];
protected $container;
public function __construct(Container $container)
{
$this->container = $container;
}
public function send(Notification $notification)
{
foreach ($this->methods as $method => $sender) {
$sender = $this->container->make($sender);
if ($notification->getRecipient()->shouldNotify($notification::getType(), $method) && $sender->compatibleWith($notification)) {
$sender->send($notification);
}
}
}
public function registerMethod($name, $class)
{
$this->methods[$name] = $class;
}
public function registerType($class, $defaultPreferences = [])
{
$this->types[] = $class;
NotificationModel::registerType($class);
foreach ($this->methods as $method => $sender) {
$sender = $this->container->make($sender);
if ($sender->compatibleWith($class)) {
User::registerPreference(User::notificationPreferenceKey($class::getType(), $method), 'boolval', array_get($defaultPreferences, $method, false));
}
}
}
public function getMethods()
{
return $this->methods;
}
public function getTypes()
{
return $this->types;
}
}

View File

@ -0,0 +1,25 @@
<?php namespace Flarum\Core\Notifications\Senders;
use Flarum\Core\Notifications\Types\Notification;
use Flarum\Core\Models\Notification as NotificationModel;
use ReflectionClass;
class NotificationAlerter implements NotificationSender
{
public function send(Notification $notification)
{
$model = NotificationModel::alert(
$notification->getRecipient()->id,
$notification::getType(),
$notification->getSender()->id,
$notification->getSubject()->id,
$notification->getAlertData()
);
$model->save();
}
public function compatibleWith($className)
{
return (new ReflectionClass($className))->implementsInterface('Flarum\Core\Notifications\Types\AlertableNotification');
}
}

View File

@ -0,0 +1,29 @@
<?php namespace Flarum\Core\Notifications\Senders;
use Flarum\Core\Notifications\Types\Notification;
use Flarum\Core\Models\Forum;
use Illuminate\Mail\Mailer;
use ReflectionClass;
class NotificationEmailer implements NotificationSender
{
public function __construct(Mailer $mailer, Forum $forum)
{
$this->mailer = $mailer;
$this->forum = $forum;
}
public function send(Notification $notification)
{
$this->mailer->send($notification->getEmailView(), ['notification' => $notification], function ($message) use ($notification) {
$recipient = $notification->getRecipient();
$message->to($recipient->email, $recipient->username)
->subject('['.$this->forum->title.'] '.$notification->getEmailSubject());
});
}
public function compatibleWith($class)
{
return (new ReflectionClass($class))->implementsInterface('Flarum\Core\Notifications\Types\EmailableNotification');
}
}

View File

@ -0,0 +1,10 @@
<?php namespace Flarum\Core\Notifications\Senders;
use Flarum\Core\Notifications\Types\Notification;
interface NotificationSender
{
public function send(Notification $notification);
public function compatibleWith($class);
}

View File

@ -0,0 +1,8 @@
<?php namespace Flarum\Core\Notifications\Types;
interface AlertableNotification
{
public function getAlertData();
public static function getType();
}

View File

@ -0,0 +1,42 @@
<?php namespace Flarum\Core\Notifications\Types;
use Flarum\Core\Models\User;
use Flarum\Core\Models\DiscussionRenamedPost;
class DiscussionRenamedNotification extends Notification implements AlertableNotification
{
public $post;
public $oldTitle;
public function __construct(User $recipient, User $sender, DiscussionRenamedPost $post, $oldTitle)
{
$this->post = $post;
$this->oldTitle = $oldTitle;
parent::__construct($recipient, $sender);
}
public function getSubject()
{
return $this->post->discussion;
}
public function getAlertData()
{
return [
'number' => $this->post->number,
'oldTitle' => $this->oldTitle
];
}
public static function getType()
{
return 'discussionRenamed';
}
public static function getSubjectModel()
{
return 'Flarum\Core\Models\Discussion';
}
}

View File

@ -0,0 +1,8 @@
<?php namespace Flarum\Core\Notifications\Types;
interface EmailableNotification
{
public function getEmailView();
public function getEmailSubject();
}

View File

@ -0,0 +1,36 @@
<?php namespace Flarum\Core\Notifications\Types;
use Flarum\Core\Models\User;
abstract class Notification
{
protected $recipient;
protected $sender;
public function __construct(User $recipient, User $sender)
{
$this->recipient = $recipient;
$this->sender = $sender;
}
public function getRecipient()
{
return $this->recipient;
}
public function getSender()
{
return $this->sender;
}
public static function getType()
{
return null;
}
public static function getSubjectModel()
{
return null;
}
}