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 + +
+
+ {{ui/action-button class="btn btn-icon btn-link btn-sm" icon="check" title="Mark All as Read" action="markAllAsRead"}} +

Notifications

+
+ + {{#if notificationsLoading}} + {{ui/loading-indicator}} + {{/if}} +
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]; + } +}