commit 5ea3579f76475952ded658dcf16f48e7af6a2411 Author: Toby Zerner Date: Fri Jun 26 12:24:07 2015 +0930 Initial commit diff --git a/extensions/subscriptions/.gitignore b/extensions/subscriptions/.gitignore new file mode 100644 index 000000000..a4f3b125e --- /dev/null +++ b/extensions/subscriptions/.gitignore @@ -0,0 +1,4 @@ +/vendor +composer.phar +.DS_Store +Thumbs.db diff --git a/extensions/subscriptions/LICENSE.txt b/extensions/subscriptions/LICENSE.txt new file mode 100644 index 000000000..aa1e5fb86 --- /dev/null +++ b/extensions/subscriptions/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 Toby Zerner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/subscriptions/bootstrap.php b/extensions/subscriptions/bootstrap.php new file mode 100644 index 000000000..d4aeb005f --- /dev/null +++ b/extensions/subscriptions/bootstrap.php @@ -0,0 +1,9 @@ +app->register('Flarum\Subscriptions\SubscriptionsServiceProvider'); diff --git a/extensions/subscriptions/composer.json b/extensions/subscriptions/composer.json new file mode 100644 index 000000000..9309d25d8 --- /dev/null +++ b/extensions/subscriptions/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "Flarum\\Subscriptions\\": "src/" + } + } +} diff --git a/extensions/subscriptions/flarum.json b/extensions/subscriptions/flarum.json new file mode 100644 index 000000000..90107ae87 --- /dev/null +++ b/extensions/subscriptions/flarum.json @@ -0,0 +1,16 @@ +{ + "name": "flarum-subscriptions", + "title": "Subscriptions", + "description": "Allow users to follow discussions and receive notifications for new posts.", + "tags": [], + "version": "0.1.0", + "author": { + "name": "Toby Zerner", + "email": "toby@flarum.org" + }, + "license": "MIT", + "require": { + "php": ">=5.4.0", + "flarum": ">0.1.0" + } +} \ No newline at end of file diff --git a/extensions/subscriptions/js/.gitignore b/extensions/subscriptions/js/.gitignore new file mode 100644 index 000000000..372e20a51 --- /dev/null +++ b/extensions/subscriptions/js/.gitignore @@ -0,0 +1,3 @@ +bower_components +node_modules +dist diff --git a/extensions/subscriptions/js/Gulpfile.js b/extensions/subscriptions/js/Gulpfile.js new file mode 100644 index 000000000..41e50fe34 --- /dev/null +++ b/extensions/subscriptions/js/Gulpfile.js @@ -0,0 +1,5 @@ +var gulp = require('flarum-gulp'); + +gulp({ + modulePrefix: 'flarum-subscriptions' +}); diff --git a/extensions/subscriptions/js/bootstrap.js b/extensions/subscriptions/js/bootstrap.js new file mode 100644 index 000000000..219caf426 --- /dev/null +++ b/extensions/subscriptions/js/bootstrap.js @@ -0,0 +1,93 @@ +import { extend, override } from 'flarum/extension-utils'; +import app from 'flarum/app'; +import Model from 'flarum/model'; +import Component from 'flarum/component'; +import Discussion from 'flarum/models/discussion'; +import Badge from 'flarum/components/badge'; +import ActionButton from 'flarum/components/action-button'; +import SettingsPage from 'flarum/components/settings-page'; +import DiscussionPage from 'flarum/components/discussion-page'; +import IndexPage from 'flarum/components/index-page'; +import IndexNavItem from 'flarum/components/index-nav-item'; +import DiscussionList from 'flarum/components/discussion-list'; +import icon from 'flarum/helpers/icon'; + +import SubscriptionMenu from 'flarum-subscriptions/components/subscription-menu'; +import NewPostNotification from 'flarum-subscriptions/components/new-post-notification'; + +app.initializers.add('flarum-subscriptions', function() { + + app.notificationComponentRegistry['newPost'] = NewPostNotification; + + Discussion.prototype.subscription = Model.prop('subscription'); + + // Add subscription badges to discussions. + extend(Discussion.prototype, 'badges', function(badges) { + var badge; + + switch (this.subscription()) { + case 'follow': + badge = Badge.component({ label: 'Following', icon: 'star', className: 'badge-follow' }); + break; + + case 'ignore': + badge = Badge.component({ label: 'Ignoring', icon: 'eye-slash', className: 'badge-ignore' }); + } + + if (badge) { + badges.add('subscription', badge); + } + }); + + extend(Discussion.prototype, 'userControls', function(items, context) { + if (app.session.user() && !(context instanceof DiscussionPage)) { + var states = { + none: {label: 'Follow', icon: 'star', save: 'follow'}, + follow: {label: 'Unfollow', icon: 'star-o', save: false}, + ignore: {label: 'Unignore', icon: 'eye', save: false} + }; + var subscription = this.subscription() || 'none'; + + items.add('subscription', ActionButton.component({ + label: states[subscription].label, + icon: states[subscription].icon, + onclick: this.save.bind(this, {subscription: states[subscription].save}) + })); + } + }); + + extend(DiscussionPage.prototype, 'sidebarItems', function(items) { + if (app.session.user()) { + var discussion = this.discussion(); + items.add('subscription', SubscriptionMenu.component({discussion}), {after: 'controls'}); + } + }); + + extend(IndexPage.prototype, 'navItems', function(items) { + if (app.session.user()) { + var params = this.stickyParams(); + params.filter = 'following'; + + items.add('following', IndexNavItem.component({ + href: app.route('index.filter', params), + label: 'Following', + icon: 'star' + }), {after: 'allDiscussions'}); + } + }); + + extend(DiscussionList.prototype, 'params', function(params) { + if (params.filter === 'following') { + params.q = (params.q || '')+' is:following'; + } + }); + + // Add a notification preference. + extend(SettingsPage.prototype, 'notificationTypes', function(items) { + items.add('newPost', { + name: 'newPost', + label: [icon('star'), " Someone posts in a discussion I'm following"] + }); + }); + +}); diff --git a/extensions/subscriptions/js/package.json b/extensions/subscriptions/js/package.json new file mode 100644 index 000000000..3e0ef919d --- /dev/null +++ b/extensions/subscriptions/js/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "devDependencies": { + "gulp": "^3.8.11", + "flarum-gulp": "git+https://github.com/flarum/gulp.git" + } +} diff --git a/extensions/subscriptions/js/src/components/new-post-notification.js b/extensions/subscriptions/js/src/components/new-post-notification.js new file mode 100644 index 000000000..a2ccd259d --- /dev/null +++ b/extensions/subscriptions/js/src/components/new-post-notification.js @@ -0,0 +1,16 @@ +import Notification from 'flarum/components/notification'; +import username from 'flarum/helpers/username'; + +export default class NewPostNotification extends Notification { + view() { + var notification = this.props.notification; + var discussion = notification.subject(); + var content = notification.content() || {}; + + return super.view({ + href: app.route.discussion(discussion, content.postNumber), + icon: 'star', + content: [username(notification.sender()), ' posted'] + }); + } +} diff --git a/extensions/subscriptions/js/src/components/subscription-menu-item.js b/extensions/subscriptions/js/src/components/subscription-menu-item.js new file mode 100644 index 000000000..d388c221e --- /dev/null +++ b/extensions/subscriptions/js/src/components/subscription-menu-item.js @@ -0,0 +1,17 @@ +import Component from 'flarum/component'; +import icon from 'flarum/helpers/icon'; + +export default class SubscriptionMenuItem extends Component { + view() { + return m('a.subscription-menu-item.has-icon[href=javascript:;]', { + onclick: this.props.onclick + }, [ + this.props.active ? icon('check icon') : '', + m('span.label', + icon(this.props.icon+' icon'), + m('strong', this.props.label), + m('span.description', this.props.description) + ) + ]); + } +} diff --git a/extensions/subscriptions/js/src/components/subscription-menu.js b/extensions/subscriptions/js/src/components/subscription-menu.js new file mode 100644 index 000000000..68cc308f0 --- /dev/null +++ b/extensions/subscriptions/js/src/components/subscription-menu.js @@ -0,0 +1,54 @@ +import Component from 'flarum/component'; +import ActionButton from 'flarum/components/action-button'; +import icon from 'flarum/helpers/icon'; + +import SubscriptionMenuItem from 'flarum-subscriptions/components/subscription-menu-item'; + +export default class SubscriptionMenu extends Component { + view() { + var discussion = this.props.discussion; + var subscription = discussion.subscription(); + + var buttonLabel = 'Follow'; + var buttonIcon = 'star-o'; + var buttonClass = 'btn-subscription-'+subscription; + + switch (subscription) { + case 'follow': + buttonLabel = 'Following'; + buttonIcon = 'star'; + break; + + case 'ignore': + buttonLabel = 'Ignoring'; + buttonIcon = 'eye-slash'; + } + + var options = [ + {subscription: false, icon: 'star-o', label: 'Not Following', description: 'Be notified when @mentioned.'}, + {subscription: 'follow', icon: 'star', label: 'Following', description: 'Be notified of all replies.'}, + {subscription: 'ignore', icon: 'eye-slash', label: 'Ignoring', description: 'Never be notified. Hide from the discussion list.'} + ]; + + return m('div.dropdown.btn-group.subscription-menu', [ + ActionButton.component({ + className: 'btn btn-default '+buttonClass, + icon: buttonIcon, + label: buttonLabel, + onclick: this.saveSubscription.bind(this, discussion, ['follow', 'ignore'].indexOf(subscription) !== -1 ? false : 'follow') + }), + + m('a.dropdown-toggle.btn.btn-default.btn-icon[href=javascript:;][data-toggle=dropdown]', {className: buttonClass}, icon('caret-down icon-caret')), + + m('ul.dropdown-menu.pull-right', options.map(props => { + props.onclick = this.saveSubscription.bind(this, discussion, props.subscription); + props.active = subscription === props.subscription; + return m('li', SubscriptionMenuItem.component(props)); + })) + ]); + } + + saveSubscription(discussion, subscription) { + discussion.save({subscription}); + } +} diff --git a/extensions/subscriptions/less/extension.less b/extensions/subscriptions/less/extension.less new file mode 100644 index 000000000..1ebc4c3ae --- /dev/null +++ b/extensions/subscriptions/less/extension.less @@ -0,0 +1,29 @@ +.badge-follow { + background: #fc0; +} +.badge-ignore { + background: #aaa; +} +.btn-subscription-follow { + .button-variant(#de8e00, #fff2ae, #fff2ae); +} +.subscription-menu .dropdown-menu { + min-width: 260px; +} +.subscription-menu-item { + & .label { + padding-left: 25px; + display: block; + white-space: normal; + + & strong { + display: block; + } + & .description { + display: block; + color: @fl-body-muted-color; + font-size: 12px; + margin-top: 3px; + } + } +} diff --git a/extensions/subscriptions/locale/en.yml b/extensions/subscriptions/locale/en.yml new file mode 100644 index 000000000..16a9b2eb3 --- /dev/null +++ b/extensions/subscriptions/locale/en.yml @@ -0,0 +1,2 @@ +flarum-subscriptions: + # hello_world: Hello, world! diff --git a/extensions/subscriptions/migrations/2015_05_11_000000_add_subscription_to_users_discussions_table.php b/extensions/subscriptions/migrations/2015_05_11_000000_add_subscription_to_users_discussions_table.php new file mode 100644 index 000000000..0304ac304 --- /dev/null +++ b/extensions/subscriptions/migrations/2015_05_11_000000_add_subscription_to_users_discussions_table.php @@ -0,0 +1,31 @@ +getSchemaBuilder()->table('users_discussions', function (Blueprint $table) { + $table->enum('subscription', ['follow', 'ignore'])->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + app('db')->getSchemaBuilder()->table('users_discussions', function (Blueprint $table) { + $table->dropColumn('subscription'); + }); + } +} diff --git a/extensions/subscriptions/src/Handlers/NewPostNotifier.php b/extensions/subscriptions/src/Handlers/NewPostNotifier.php new file mode 100755 index 000000000..ae9b8a43f --- /dev/null +++ b/extensions/subscriptions/src/Handlers/NewPostNotifier.php @@ -0,0 +1,72 @@ +notifications = $notifications; + } + + /** + * Register the listeners for the subscriber. + * + * @param \Illuminate\Contracts\Events\Dispatcher $events + */ + public function subscribe(Dispatcher $events) + { + // Register with '1' as priority so this runs before discussion metadata + // is updated, as we need to compare the user's last read number to that + // of the previous post. + $events->listen('Flarum\Core\Events\PostWasPosted', __CLASS__.'@whenPostWasPosted', 1); + $events->listen('Flarum\Core\Events\PostWasHidden', __CLASS__.'@whenPostWasHidden'); + $events->listen('Flarum\Core\Events\PostWasRestored', __CLASS__.'@whenPostWasRestored'); + $events->listen('Flarum\Core\Events\PostWasDeleted', __CLASS__.'@whenPostWasDeleted'); + } + + public function whenPostWasPosted(PostWasPosted $event) + { + $post = $event->post; + $discussion = $post->discussion; + + $notify = $discussion->readers() + ->where('users.id', '!=', $post->user_id) + ->where('users_discussions.subscription', 'follow') + ->where('users_discussions.read_number', $discussion->last_post_number) + ->get(); + + $this->notifications->sync( + $this->getNotification($event->post), + $notify->all() + ); + } + + public function whenPostWasHidden(PostWasHidden $event) + { + $this->notifications->delete($this->getNotification($event->post)); + } + + public function whenPostWasRestored(PostWasRestored $event) + { + $this->notifications->restore($this->getNotification($event->post)); + } + + public function whenPostWasDeleted(PostWasDeleted $event) + { + $this->notifications->delete($this->getNotification($event->post)); + } + + protected function getNotification($post) + { + return new NewPostNotification($post); + } +} diff --git a/extensions/subscriptions/src/Handlers/SubscriptionSaver.php b/extensions/subscriptions/src/Handlers/SubscriptionSaver.php new file mode 100755 index 000000000..e0dfe863c --- /dev/null +++ b/extensions/subscriptions/src/Handlers/SubscriptionSaver.php @@ -0,0 +1,31 @@ +listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); + } + + public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) + { + $discussion = $event->discussion; + $data = $event->command->data; + + if (isset($data['subscription'])) { + $user = $event->command->user; + $subscription = $data['subscription']; + + $state = $discussion->stateFor($user); + + if (! in_array($subscription, ['follow', 'ignore'])) { + $subscription = null; + } + + $state->subscription = $subscription; + $state->save(); + } + } +} diff --git a/extensions/subscriptions/src/Handlers/SubscriptionSearchModifier.php b/extensions/subscriptions/src/Handlers/SubscriptionSearchModifier.php new file mode 100755 index 000000000..a40268dee --- /dev/null +++ b/extensions/subscriptions/src/Handlers/SubscriptionSearchModifier.php @@ -0,0 +1,26 @@ +listen('Flarum\Core\Events\DiscussionSearchWillBePerformed', __CLASS__.'@filterIgnored'); + } + + public function filterIgnored(DiscussionSearchWillBePerformed $event) + { + if (! $event->criteria->query) { + // might be better as `id IN (subquery)`? + $user = $event->criteria->user; + $event->searcher->getQuery()->whereNotExists(function ($query) use ($user) { + $query->select(app('db')->raw(1)) + ->from('users_discussions') + ->whereRaw('discussion_id = discussions.id') + ->where('user_id', $user->id) + ->where('subscription', 'ignore'); + }); + } + } +} diff --git a/extensions/subscriptions/src/NewPostNotification.php b/extensions/subscriptions/src/NewPostNotification.php new file mode 100644 index 000000000..38ce0581b --- /dev/null +++ b/extensions/subscriptions/src/NewPostNotification.php @@ -0,0 +1,55 @@ +post = $post; + } + + public function getSubject() + { + return $this->post->discussion; + } + + public function getSender() + { + return $this->post->user; + } + + public function getData() + { + return ['postNumber' => (int) $this->post->number]; + } + + public function getEmailView() + { + return ['text' => 'flarum-subscriptions::emails.newPost']; + } + + public function getEmailSubject() + { + return '[New Post] '.$this->post->discussion->title; + } + + public static function getType() + { + return 'newPost'; + } + + public static function getSubjectModel() + { + return 'Flarum\Core\Models\Discussion'; + } + + public static function isEmailable() + { + return true; + } +} diff --git a/extensions/subscriptions/src/SubscriptionGambit.php b/extensions/subscriptions/src/SubscriptionGambit.php new file mode 100644 index 000000000..b9c734d4e --- /dev/null +++ b/extensions/subscriptions/src/SubscriptionGambit.php @@ -0,0 +1,37 @@ +getUser(); + + // might be better as `id IN (subquery)`? + $method = $negate ? 'whereNotExists' : 'whereExists'; + $searcher->getQuery()->$method(function ($query) use ($user, $matches) { + $query->select(app('db')->raw(1)) + ->from('users_discussions') + ->whereRaw('discussion_id = discussions.id') + ->where('user_id', $user->id) + ->where('subscription', $matches[1] === 'follow' ? 'follow' : 'ignore'); + }); + } +} diff --git a/extensions/subscriptions/src/SubscriptionsServiceProvider.php b/extensions/subscriptions/src/SubscriptionsServiceProvider.php new file mode 100644 index 000000000..e1543173c --- /dev/null +++ b/extensions/subscriptions/src/SubscriptionsServiceProvider.php @@ -0,0 +1,59 @@ +loadViewsFrom(__DIR__.'/../views', 'flarum-subscriptions'); + + $this->extend([ + (new Extend\Locale('en'))->translations(__DIR__.'/../locale/en.yml'), + + (new Extend\ForumClient()) + ->assets([ + __DIR__.'/../js/dist/extension.js', + __DIR__.'/../less/extension.less' + ]) + ->translations([ + // Add the keys of translations you would like to be available + // for use by the JS client application. + ]) + ->route('get', '/following', 'flarum.forum.following'), + + (new Extend\ApiSerializer('Flarum\Api\Serializers\DiscussionSerializer')) + ->attributes(function (&$attributes, $discussion, $user) { + if ($state = $discussion->stateFor($user)) { + $attributes['subscription'] = $state->subscription ?: false; + } + }), + + new Extend\EventSubscriber('Flarum\Subscriptions\Handlers\SubscriptionSaver'), + new Extend\EventSubscriber('Flarum\Subscriptions\Handlers\SubscriptionSearchModifier'), + new Extend\EventSubscriber('Flarum\Subscriptions\Handlers\NewPostNotifier'), + + new Extend\DiscussionGambit('Flarum\Subscriptions\SubscriptionGambit'), + + (new Extend\NotificationType('Flarum\Subscriptions\NewPostNotification', 'Flarum\Api\Serializers\DiscussionBasicSerializer')) + ->enableByDefault('alert') + ->enableByDefault('email') + ]); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + // + } +} diff --git a/extensions/subscriptions/views/emails/newPost.blade.php b/extensions/subscriptions/views/emails/newPost.blade.php new file mode 100644 index 000000000..0cc3a2af8 --- /dev/null +++ b/extensions/subscriptions/views/emails/newPost.blade.php @@ -0,0 +1,14 @@ +Hey {{ $user->username }}! + +{{ $notification->post->user->username }} made a post in a discussion you're following: {{ $notification->post->discussion->title }} + +To view the new activity, check out the following link: +{{ \Flarum\Core::config('base_url') }}/d/{{ $notification->post->discussion_id }}/-/{{ $notification->post->number }} + +--- + +{{ strip_tags($notification->post->contentHtml) }} + +--- + +You won't receive any more notifications about this discussion until you're up-to-date.