diff --git a/ember/app/components/application/notification-item.js b/ember/app/components/application/notification-item.js
new file mode 100644
index 000000000..c91f13ac0
--- /dev/null
+++ b/ember/app/components/application/notification-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/application/notification-item',
+ tagName: 'li',
+
+ componentName: Ember.computed('notification.contentType', function() {
+ return 'application/notification-'+this.get('notification.contentType');
+ })
+});
diff --git a/ember/app/components/application/notification-renamed.js b/ember/app/components/application/notification-renamed.js
new file mode 100644
index 000000000..bdff99203
--- /dev/null
+++ b/ember/app/components/application/notification-renamed.js
@@ -0,0 +1,3 @@
+import Notification from './notification';
+
+export default Notification.extend();
diff --git a/ember/app/components/application/notification.js b/ember/app/components/application/notification.js
new file mode 100644
index 000000000..e28a22a1c
--- /dev/null
+++ b/ember/app/components/application/notification.js
@@ -0,0 +1,11 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+ classNames: ['notification'],
+ classNameBindings: ['notification.isRead::unread'],
+
+ click: function() {
+ console.log('click')
+ this.get('notification').set('isRead', true).save();
+ }
+});
diff --git a/ember/app/components/application/user-notifications.js b/ember/app/components/application/user-notifications.js
new file mode 100644
index 000000000..cfb00ddc1
--- /dev/null
+++ b/ember/app/components/application/user-notifications.js
@@ -0,0 +1,39 @@
+import Ember from 'ember';
+
+import DropdownButton from 'flarum/components/ui/dropdown-button';
+
+var precompileTemplate = Ember.Handlebars.compile;
+
+export default DropdownButton.extend({
+ layoutName: 'components/application/user-notifications',
+ classNames: ['notifications'],
+ classNameBindings: ['unread'],
+
+ buttonClass: 'btn btn-default btn-rounded btn-naked btn-icon',
+ menuClass: 'pull-right',
+
+ unread: Ember.computed.bool('user.unreadNotificationsCount'),
+
+ actions: {
+ buttonClick: function() {
+ if (!this.get('notifications')) {
+ var component = this;
+ this.set('notificationsLoading', true);
+ this.get('parentController.store').find('notification').then(function(notifications) {
+ component.set('user.unreadNotificationsCount', 0);
+ component.set('notifications', notifications);
+ component.set('notificationsLoading', false);
+ });
+ }
+ },
+
+ markAllAsRead: function() {
+ this.get('notifications').forEach(function(notification) {
+ if (!notification.get('isRead')) {
+ notification.set('isRead', true);
+ notification.save();
+ }
+ })
+ },
+ }
+})
diff --git a/ember/app/models/discussion.js b/ember/app/models/discussion.js
index c0c338192..15fd4b9e5 100644
--- a/ember/app/models/discussion.js
+++ b/ember/app/models/discussion.js
@@ -2,8 +2,9 @@ import Ember from 'ember';
import DS from 'ember-data';
import HasItemLists from 'flarum/mixins/has-item-lists';
+import Subject from './subject';
-export default DS.Model.extend(HasItemLists, {
+export default Subject.extend(HasItemLists, {
/**
Define a "badges" item list. Example usage:
```
diff --git a/ember/app/models/notification.js b/ember/app/models/notification.js
new file mode 100644
index 000000000..98f04f317
--- /dev/null
+++ b/ember/app/models/notification.js
@@ -0,0 +1,21 @@
+import DS from 'ember-data';
+
+export default DS.Model.extend({
+ contentType: DS.attr('string'),
+ subjectId: DS.attr('number'),
+ content: DS.attr(),
+ time: DS.attr('date'),
+ isRead: DS.attr('boolean'),
+ unreadCount: DS.attr('number'),
+ additionalUnreadCount: Ember.computed('unreadCount', function() {
+ return Math.max(0, this.get('unreadCount') - 1);
+ }),
+
+ decodedContent: Ember.computed('content', function() {
+ return JSON.parse(this.get('content'));
+ }),
+
+ user: DS.belongsTo('user'),
+ sender: DS.belongsTo('user'),
+ subject: DS.belongsTo('subject', {polymorphic: true})
+});
diff --git a/ember/app/models/post.js b/ember/app/models/post.js
index 8eb3e5a96..f91677257 100644
--- a/ember/app/models/post.js
+++ b/ember/app/models/post.js
@@ -1,7 +1,8 @@
import Ember from 'ember';
import DS from 'ember-data';
+import Subject from './subject';
-export default DS.Model.extend({
+export default Subject.extend({
discussion: DS.belongsTo('discussion', {inverse: 'loadedPosts'}),
number: DS.attr('number'),
diff --git a/ember/app/models/subject.js b/ember/app/models/subject.js
new file mode 100644
index 000000000..d7b7b0561
--- /dev/null
+++ b/ember/app/models/subject.js
@@ -0,0 +1,5 @@
+import DS from 'ember-data';
+
+export default DS.Model.extend({
+ notification: DS.belongsTo('notification')
+});
diff --git a/ember/app/models/user.js b/ember/app/models/user.js
index 5235b9a3f..ebbecaaf6 100644
--- a/ember/app/models/user.js
+++ b/ember/app/models/user.js
@@ -18,6 +18,7 @@ export default DS.Model.extend(HasItemLists, {
joinTime: DS.attr('date'),
lastSeenTime: DS.attr('date'),
readTime: DS.attr('date'),
+ unreadNotificationsCount: DS.attr('number'),
discussionsCount: DS.attr('number'),
commentsCount: DS.attr('number'),
diff --git a/ember/app/styles/app.less b/ember/app/styles/app.less
index 93525dbd0..dd963f4c6 100644
--- a/ember/app/styles/app.less
+++ b/ember/app/styles/app.less
@@ -30,6 +30,7 @@
@import "@{flarum-base}modals.less";
@import "@{flarum-base}layout.less";
@import "@{flarum-base}composer.less";
+@import "@{flarum-base}notifications.less";
@import "@{flarum-base}index.less";
@import "@{flarum-base}discussion.less";
diff --git a/ember/app/styles/flarum/dropdowns.less b/ember/app/styles/flarum/dropdowns.less
index 8510993d8..fdef09008 100644
--- a/ember/app/styles/flarum/dropdowns.less
+++ b/ember/app/styles/flarum/dropdowns.less
@@ -3,6 +3,7 @@
padding: 8px 0;
margin-top: 7px;
background: @fl-body-bg;
+ color: @fl-body-color;
.box-shadow(0 2px 6px @fl-shadow-color);
& > li > a {
diff --git a/ember/app/styles/flarum/layout.less b/ember/app/styles/flarum/layout.less
index acc6d2649..a6b79d0e6 100644
--- a/ember/app/styles/flarum/layout.less
+++ b/ember/app/styles/flarum/layout.less
@@ -154,7 +154,7 @@ body {
color: @fl-drawer-color;
}
}
- &, & a, & .btn-link {
+ &:not(.dropdown-menu), & a:not(.dropdown-menu a), & .btn-link:not(.dropdown-menu .btn-link) {
color: @fl-drawer-control-color;
}
& .form-control {
@@ -207,6 +207,10 @@ body {
visibility: visible;
transition-delay: 0s;
}
+
+ & .dropdown-menu {
+ width: @drawer-width !important;
+ }
}
}
@@ -247,6 +251,11 @@ body {
width: 100%;
text-align: left;
}
+ & .dropdown-menu {
+ & .btn-group, & .btn {
+ width: auto;
+ }
+ }
}
}
@@ -279,7 +288,7 @@ body {
.header-controls {
&, & > li {
display: inline-block;
- vertical-align: top;
+ vertical-align: middle;
}
}
.header-primary {
diff --git a/ember/app/styles/flarum/notifications.less b/ember/app/styles/flarum/notifications.less
new file mode 100644
index 000000000..ea8552020
--- /dev/null
+++ b/ember/app/styles/flarum/notifications.less
@@ -0,0 +1,114 @@
+.notifications {
+ & .dropdown-menu {
+ padding: 0;
+ overflow: hidden;
+ }
+ & .loading-indicator {
+ height: 100px;
+ }
+ & .dropdown-toggle .label {
+ margin-left: 5px;
+ }
+}
+@media @tablet, @desktop, @desktop-hd {
+ .notifications {
+ & .dropdown-menu {
+ width: 400px;
+ }
+ & .dropdown-toggle .label {
+ display: none;
+ }
+ }
+}
+
+.notifications-icon {
+ display: inline-block;
+ border-radius: 12px;
+ height: 24px;
+ width: 24px;
+ text-align: center;
+ padding: 2px 0;
+ font-weight: bold;
+ margin: -2px -3px;
+}
+&.unread .notifications-icon {
+ background: #e7562e;
+ color: #fff;
+}
+.notifications-header {
+ padding: 12px 15px;
+ border-bottom: 1px solid @fl-body-secondary-color;
+
+ & h4 {
+ font-size: 12px;
+ text-transform: uppercase;
+ font-weight: bold;
+ margin: 0;
+ }
+ & .btn {
+ float: right;
+ margin-top: -5px;
+ margin-right: -5px;
+ }
+}
+.notifications-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 600px;
+ overflow: auto;
+}
+.no-notifications {
+ color: @fl-body-muted-color;
+ text-align: center;
+ padding: 50px 0;
+ font-size: 16px;
+}
+.notification {
+ & > a {
+ display: block;
+ padding: 15px 15px 15px 75px;
+ color: @fl-body-muted-color;
+ overflow: hidden;
+
+ .unread& {
+ background: @fl-body-secondary-color;
+ }
+ &:hover {
+ text-decoration: none;
+ background: @fl-body-secondary-color;
+ }
+ }
+ & .avatar {
+ float: left;
+ margin-left: -60px;
+ }
+}
+.notification-title {
+ color: @fl-body-heading-color;
+ font-size: 13px;
+ font-weight: bold;
+ margin: 0 0 6px;
+ line-height: 1.5em;
+}
+.notification-info {
+ font-size: 12px;
+
+ & .fa {
+ font-size: 14px;
+ }
+ & .username {
+ font-weight: bold;
+ }
+}
+@media @phone {
+ .notification {
+ & > a {
+ padding-left: 60px;
+ }
+ & .avatar {
+ margin-left: -45px;
+ .avatar-size(32px);
+ }
+ }
+}
diff --git a/ember/app/templates/components/application/notification-item.hbs b/ember/app/templates/components/application/notification-item.hbs
new file mode 100644
index 000000000..21e7113b0
--- /dev/null
+++ b/ember/app/templates/components/application/notification-item.hbs
@@ -0,0 +1 @@
+{{component componentName notification=notification}}
diff --git a/ember/app/templates/components/application/notification-renamed.hbs b/ember/app/templates/components/application/notification-renamed.hbs
new file mode 100644
index 000000000..8f468b13c
--- /dev/null
+++ b/ember/app/templates/components/application/notification-renamed.hbs
@@ -0,0 +1,14 @@
+{{#link-to "discussion" notification.subject (query-params start=notification.content.number)}}
+ {{user-avatar notification.sender}}
+
+
{{notification.content.oldTitle}}
+
+
+ {{fa-icon "pencil"}}
+ Renamed by {{user-name notification.sender}}
+ {{#if notification.additionalUnreadCount}}
+ and {{notification.additionalUnreadCount}} others
+ {{/if}}
+ {{human-time notification.time}}
+
+{{/link-to}}
diff --git a/ember/app/templates/components/application/user-notifications.hbs b/ember/app/templates/components/application/user-notifications.hbs
new file mode 100644
index 000000000..e2956932b
--- /dev/null
+++ b/ember/app/templates/components/application/user-notifications.hbs
@@ -0,0 +1,26 @@
+
+
+ {{#if unread}}
+ {{user.unreadNotificationsCount}}
+ {{else}}
+ {{fa-icon "bell" class="icon-glyph"}}
+ {{/if}}
+
+ Notifications
+
+
diff --git a/ember/app/views/application.js b/ember/app/views/application.js
index 7a07bd80c..573c59934 100644
--- a/ember/app/views/application.js
+++ b/ember/app/views/application.js
@@ -2,6 +2,7 @@ import Ember from 'ember';
import HasItemLists from 'flarum/mixins/has-item-lists';
import SearchInput from 'flarum/components/ui/search-input';
+import UserNotifications from 'flarum/components/application/user-notifications';
import UserDropdown from 'flarum/components/application/user-dropdown';
import ForumStatistic from 'flarum/components/application/forum-statistic';
import PoweredBy from 'flarum/components/application/powered-by';
@@ -88,6 +89,11 @@ export default Ember.View.extend(HasItemLists, {
}), 'search');
if (this.get('controller.session.isAuthenticated')) {
+ items.pushObjectWithTag(UserNotifications.extend({
+ user: this.get('controller.session.user'),
+ parentController: controller
+ }), 'notifications');
+
items.pushObjectWithTag(UserDropdown.extend({
user: this.get('controller.session.user'),
parentController: controller
diff --git a/migrations/2015_02_24_000000_create_notifications_table.php b/migrations/2015_02_24_000000_create_notifications_table.php
new file mode 100644
index 000000000..f2684931e
--- /dev/null
+++ b/migrations/2015_02_24_000000_create_notifications_table.php
@@ -0,0 +1,39 @@
+increments('id');
+ $table->integer('user_id')->unsigned();
+ $table->integer('sender_id')->unsigned()->nullable();
+ $table->string('type');
+ $table->string('subject_type')->nullable();
+ $table->integer('subject_id')->unsigned()->nullable();
+ $table->binary('data')->nullable();
+ $table->dateTime('time');
+ $table->boolean('is_read')->default(0);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::drop('notifications');
+ }
+
+}
diff --git a/migrations/2015_02_24_000000_create_users_table.php b/migrations/2015_02_24_000000_create_users_table.php
index 7c36024b1..f9ed47aaa 100644
--- a/migrations/2015_02_24_000000_create_users_table.php
+++ b/migrations/2015_02_24_000000_create_users_table.php
@@ -26,6 +26,7 @@ class CreateUsersTable extends Migration {
$table->dateTime('join_time')->nullable();
$table->dateTime('last_seen_time')->nullable();
$table->dateTime('read_time')->nullable();
+ $table->dateTime('notification_read_time')->nullable();
$table->integer('discussions_count')->unsigned()->default(0);
$table->integer('comments_count')->unsigned()->default(0);
});
diff --git a/src/Api/Actions/Notifications/IndexAction.php b/src/Api/Actions/Notifications/IndexAction.php
new file mode 100644
index 000000000..8268f1a7d
--- /dev/null
+++ b/src/Api/Actions/Notifications/IndexAction.php
@@ -0,0 +1,50 @@
+actor = $actor;
+ $this->notifications = $notifications;
+ }
+
+ /**
+ * Show a user's notifications feed.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ protected function run(ApiParams $params)
+ {
+ $start = $params->start();
+ $count = $params->count(10, 50);
+
+ if (! $this->actor->isAuthenticated()) {
+ throw new PermissionDeniedException;
+ }
+
+ $user = $this->actor->getUser();
+
+ $notifications = $this->notifications->findByUser($user->id, $count, $start);
+
+ $user->markNotificationsAsRead()->save();
+
+ // Finally, we can set up the notification serializer and use it to create
+ // a collection of notification results.
+ $serializer = new NotificationSerializer(['sender', 'subject', 'subject.discussion']);
+ $document = $this->document()->setData($serializer->collection($notifications));
+
+ return $this->respondWithDocument($document);
+ }
+}
diff --git a/src/Api/Actions/Notifications/UpdateAction.php b/src/Api/Actions/Notifications/UpdateAction.php
new file mode 100644
index 000000000..bd0d4ccd2
--- /dev/null
+++ b/src/Api/Actions/Notifications/UpdateAction.php
@@ -0,0 +1,34 @@
+get('id');
+ $user = $this->actor->getUser();
+
+ // if ($params->get('notifications.isRead')) {
+ $command = new ReadNotificationCommand($notificationId, $user);
+ $notification = $this->dispatch($command, $params);
+ // }
+
+ // Presumably, the discussion was updated successfully. (One of the command
+ // handlers would have thrown an exception if not.) We set this
+ // discussion as our document's primary element.
+ $serializer = new NotificationSerializer;
+ $document = $this->document()->setData($serializer->resource($notification));
+
+ return $this->respondWithDocument($document);
+ }
+}
diff --git a/src/Api/Serializers/NotificationSerializer.php b/src/Api/Serializers/NotificationSerializer.php
new file mode 100644
index 000000000..f3b57fac4
--- /dev/null
+++ b/src/Api/Serializers/NotificationSerializer.php
@@ -0,0 +1,48 @@
+ (int) $notification->id,
+ 'contentType' => $notification->type,
+ 'content' => $notification->data,
+ 'time' => $notification->time->toRFC3339String(),
+ 'isRead' => (bool) $notification->is_read,
+ 'unreadCount' => $notification->unread_count
+ ];
+
+ return $this->extendAttributes($notification, $attributes);
+ }
+
+ public function user()
+ {
+ return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer');
+ }
+
+ public function sender()
+ {
+ return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer');
+ }
+
+ public function subject()
+ {
+ return $this->hasOne([
+ 'Flarum\Core\Models\Discussion' => 'Flarum\Api\Serializers\DiscussionSerializer',
+ 'Flarum\Core\Models\CommentPost' => 'Flarum\Api\Serializers\PostSerializer'
+ ]);
+ }
+}
diff --git a/src/Api/routes.php b/src/Api/routes.php
index d9af504dd..8c413b27b 100644
--- a/src/Api/routes.php
+++ b/src/Api/routes.php
@@ -11,6 +11,13 @@ $action = function ($class) {
Route::group(['prefix' => 'api', 'middleware' => 'Flarum\Api\Middleware\LoginWithHeader'], function () use ($action) {
+ // Get forum information
+ Route::get('forum', [
+ 'as' => 'flarum.api.forum.show',
+ 'uses' => $action('Flarum\Api\Actions\Forum\ShowAction')
+ ]);
+
+ // Retrieve authentication token
Route::post('token', [
'as' => 'flarum.api.token',
'uses' => $action('Flarum\Api\Actions\TokenAction')
@@ -70,6 +77,12 @@ Route::group(['prefix' => 'api', 'middleware' => 'Flarum\Api\Middleware\LoginWit
'uses' => $action('Flarum\Api\Actions\Notifications\IndexAction')
]);
+ // Mark a single notification as read
+ Route::put('notifications/{id}', [
+ 'as' => 'flarum.api.notifications.update',
+ 'uses' => $action('Flarum\Api\Actions\Notifications\UpdateAction')
+ ]);
+
/*
|--------------------------------------------------------------------------
| Discussions
diff --git a/src/Core/Commands/ReadNotificationCommand.php b/src/Core/Commands/ReadNotificationCommand.php
new file mode 100644
index 000000000..711832ce3
--- /dev/null
+++ b/src/Core/Commands/ReadNotificationCommand.php
@@ -0,0 +1,14 @@
+notificationId = $notificationId;
+ $this->user = $user;
+ }
+}
diff --git a/src/Core/CoreServiceProvider.php b/src/Core/CoreServiceProvider.php
index fb298a0b9..92279d98e 100644
--- a/src/Core/CoreServiceProvider.php
+++ b/src/Core/CoreServiceProvider.php
@@ -10,6 +10,7 @@ use Flarum\Core\Models\Model;
use Flarum\Core\Models\Forum;
use Flarum\Core\Models\User;
use Flarum\Core\Models\Discussion;
+use Flarum\Core\Models\Notification;
use Flarum\Core\Search\GambitManager;
class CoreServiceProvider extends ServiceProvider
@@ -25,6 +26,7 @@ class CoreServiceProvider extends ServiceProvider
$this->registerEventHandlers($events);
$this->registerPostTypes();
+ $this->registerNotificationTypes();
$this->registerPermissions();
$this->registerGambits();
$this->setupModels();
@@ -112,11 +114,16 @@ class CoreServiceProvider extends ServiceProvider
CommentPost::setFormatter($this->app['flarum.formatter']);
}
+ public function registerNotificationTypes()
+ {
+ Notification::addType('renamed', 'Flarum\Core\Models\Discussion');
+ }
+
public function registerEventHandlers($events)
{
$events->subscribe('Flarum\Core\Handlers\Events\DiscussionMetadataUpdater');
$events->subscribe('Flarum\Core\Handlers\Events\UserMetadataUpdater');
- $events->subscribe('Flarum\Core\Handlers\Events\RenamedPostCreator');
+ $events->subscribe('Flarum\Core\Handlers\Events\DiscussionRenamedNotifier');
$events->subscribe('Flarum\Core\Handlers\Events\EmailConfirmationMailer');
}
diff --git a/src/Core/Handlers/Commands/ReadNotificationCommandHandler.php b/src/Core/Handlers/Commands/ReadNotificationCommandHandler.php
new file mode 100644
index 000000000..c31c45231
--- /dev/null
+++ b/src/Core/Handlers/Commands/ReadNotificationCommandHandler.php
@@ -0,0 +1,28 @@
+user;
+
+ if (! $user->exists) {
+ throw new PermissionDeniedException;
+ }
+
+ $notification = Notification::where('user_id', $user->id)->findOrFail($command->notificationId);
+
+ $notification->read();
+
+ $notification->save();
+ $this->dispatchEventsFor($notification);
+
+ return $notification;
+ }
+}
diff --git a/src/Core/Handlers/Events/DiscussionRenamedNotifier.php b/src/Core/Handlers/Events/DiscussionRenamedNotifier.php
new file mode 100755
index 000000000..c9d326617
--- /dev/null
+++ b/src/Core/Handlers/Events/DiscussionRenamedNotifier.php
@@ -0,0 +1,61 @@
+listen('Flarum\Core\Events\DiscussionWasRenamed', __CLASS__.'@whenDiscussionWasRenamed');
+ }
+
+ public function whenDiscussionWasRenamed(DiscussionWasRenamed $event)
+ {
+ $post = $this->createRenamedPost($event);
+
+ $event->discussion->postWasAdded($post);
+
+ $this->createRenamedNotification($event, $post);
+ }
+
+ protected function createRenamedPost(DiscussionWasRenamed $event)
+ {
+ $post = RenamedPost::reply(
+ $event->discussion->id,
+ $event->user->id,
+ $event->oldTitle,
+ $event->discussion->title
+ );
+
+ $post->save();
+
+ return $post;
+ }
+
+ protected function createRenamedNotification(DiscussionWasRenamed $event, RenamedPost $post)
+ {
+ if ($event->discussion->start_user_id === $event->user->id) {
+ return false;
+ }
+
+ $notification = Notification::notify(
+ $event->discussion->start_user_id,
+ 'renamed',
+ $event->user->id,
+ $event->discussion->id,
+ ['number' => $post->number, 'oldTitle' => $event->oldTitle]
+ );
+
+ $notification->save();
+
+ return $notification;
+ }
+}
diff --git a/src/Core/Handlers/Events/RenamedPostCreator.php b/src/Core/Handlers/Events/RenamedPostCreator.php
deleted file mode 100755
index 6ec7fab29..000000000
--- a/src/Core/Handlers/Events/RenamedPostCreator.php
+++ /dev/null
@@ -1,32 +0,0 @@
-listen('Flarum\Core\Events\DiscussionWasRenamed', __CLASS__.'@whenDiscussionWasRenamed');
- }
-
- public function whenDiscussionWasRenamed(DiscussionWasRenamed $event)
- {
- $post = RenamedPost::reply(
- $event->discussion->id,
- $event->user->id,
- $event->oldTitle,
- $event->discussion->title
- );
-
- $post->save();
-
- $event->discussion->postWasAdded($post);
- }
-}
diff --git a/src/Core/Models/Notification.php b/src/Core/Models/Notification.php
new file mode 100644
index 000000000..63a7dd832
--- /dev/null
+++ b/src/Core/Models/Notification.php
@@ -0,0 +1,135 @@
+user_id = $userId;
+ $notification->sender_id = $senderId;
+ $notification->type = $type;
+ $notification->subject_id = $subjectId;
+ $notification->data = $data;
+ $notification->time = time();
+
+ return $notification;
+ }
+
+ public function read()
+ {
+ $this->is_read = true;
+ }
+
+ /**
+ * Unserialize the data attribute.
+ *
+ * @param string $value
+ * @return string
+ */
+ public function getDataAttribute($value)
+ {
+ return json_decode($value);
+ }
+
+ /**
+ * Serialize the data attribute.
+ *
+ * @param string $value
+ */
+ public function setDataAttribute($value)
+ {
+ $this->attributes['data'] = json_encode($value);
+ }
+
+ /**
+ * Define the relationship with the notification's recipient.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo('Flarum\Core\Models\User', 'user_id');
+ }
+
+ /**
+ * Define the relationship with the notification's sender.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function sender()
+ {
+ return $this->belongsTo('Flarum\Core\Models\User', 'sender_id');
+ }
+
+ public function subject()
+ {
+ $name = 'subject';
+ $typeColumn = 'type';
+ $idColumn = 'subject_id';
+
+ // 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
+ // there are multiple types in the morph and we can't use single queries.
+ if (is_null($type = $this->$typeColumn))
+ {
+ return new MappedMorphTo(
+ $this->newQuery(), $this, $idColumn, null, $typeColumn, $name, static::$types
+ );
+ }
+
+ // 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
+ // we will pass in the appropriate values so that it behaves as expected.
+ else
+ {
+ $class = static::$types[$type];
+ $instance = new $class;
+
+ return new MappedMorphTo(
+ $instance->newQuery(), $this, $idColumn, $instance->getKeyName(), $typeColumn, $name, static::$types
+ );
+ }
+ }
+
+ public static function getTypes()
+ {
+ return static::$types;
+ }
+
+ /**
+ * Register a notification type and its subject class.
+ *
+ * @param string $type
+ * @param string $class
+ * @return void
+ */
+ public static function addType($type, $class)
+ {
+ static::$types[$type] = $class;
+ }
+}
diff --git a/src/Core/Models/User.php b/src/Core/Models/User.php
index a61cebea9..c5e4d91ec 100755
--- a/src/Core/Models/User.php
+++ b/src/Core/Models/User.php
@@ -51,7 +51,7 @@ class User extends Model
*
* @var array
*/
- protected $dates = ['join_time', 'last_seen_time', 'read_time'];
+ protected $dates = ['join_time', 'last_seen_time', 'read_time', 'notification_read_time'];
/**
* The hasher with which to hash passwords.
@@ -198,6 +198,18 @@ class User extends Model
return $this;
}
+ /**
+ * Mark all notifications as read by setting the user's notification_read_time.
+ *
+ * @return $this
+ */
+ public function markNotificationsAsRead()
+ {
+ $this->notification_read_time = time();
+
+ return $this;
+ }
+
/**
* Check if a given password matches the user's password.
*
@@ -303,6 +315,11 @@ class User extends Model
return (bool) $count;
}
+ public function getUnreadNotificationsCount()
+ {
+ return $this->notifications()->where('time', '>', $this->notification_read_time ?: 0)->where('is_read', 0)->count(\DB::raw('DISTINCT type, subject_id'));
+ }
+
/**
* Check whether or not the user is an administrator.
*
@@ -343,6 +360,16 @@ class User extends Model
return $this->belongsToMany('Flarum\Core\Models\Group', 'users_groups');
}
+ /**
+ * Define the relationship with the user's notifications.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function notifications()
+ {
+ return $this->hasMany('Flarum\Core\Models\Notification');
+ }
+
/**
* Define the relationship with the user's permissions.
*
diff --git a/src/Core/Repositories/EloquentNotificationRepository.php b/src/Core/Repositories/EloquentNotificationRepository.php
new file mode 100644
index 000000000..dbe3f990d
--- /dev/null
+++ b/src/Core/Repositories/EloquentNotificationRepository.php
@@ -0,0 +1,25 @@
+where('user_id', $userId)
+ ->whereIn('type', array_keys(Notification::getTypes()))
+ ->groupBy('type', 'subject_id')
+ ->orderBy('time', 'desc')
+ ->skip($start)
+ ->take($count);
+
+ return Notification::with('subject')
+ ->select('notifications.*', 'p.unread_count')
+ ->mergeBindings($primaries->getQuery())
+ ->join(DB::raw('('.$primaries->toSql().') p'), 'notifications.id', '=', 'p.id')
+ ->orderBy('time', 'desc')
+ ->get();
+ }
+}
diff --git a/src/Core/Repositories/NotificationRepositoryInterface.php b/src/Core/Repositories/NotificationRepositoryInterface.php
new file mode 100644
index 000000000..cf7678ecd
--- /dev/null
+++ b/src/Core/Repositories/NotificationRepositoryInterface.php
@@ -0,0 +1,6 @@
+user = $user;
}
+
+ public function isAuthenticated()
+ {
+ return (bool) $this->user;
+ }
}
diff --git a/src/Core/Support/MappedMorphTo.php b/src/Core/Support/MappedMorphTo.php
new file mode 100644
index 000000000..121d20a2c
--- /dev/null
+++ b/src/Core/Support/MappedMorphTo.php
@@ -0,0 +1,43 @@
+types = $types;
+
+ parent::__construct($query, $parent, $foreignKey, $otherKey, $type, $relation);
+ }
+
+ /**
+ * Create a new model instance by type.
+ *
+ * @param string $type
+ * @return \Illuminate\Database\Eloquent\Model
+ */
+ public function createModelByType($type)
+ {
+ return new $this->types[$type];
+ }
+}