From 3787cc413ef7bff6b4966c3d1cb036190fe163eb Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 7 May 2015 22:26:02 +0930 Subject: [PATCH] Initial commit --- extensions/sticky/.gitignore | 4 ++ extensions/sticky/LICENSE.txt | 21 ++++++ extensions/sticky/bootstrap.php | 9 +++ extensions/sticky/composer.json | 19 ++++++ extensions/sticky/extension.json | 15 +++++ extensions/sticky/js/.gitignore | 4 ++ extensions/sticky/js/Gulpfile.js | 45 +++++++++++++ extensions/sticky/js/bootstrap.js | 60 +++++++++++++++++ extensions/sticky/js/package.json | 18 +++++ .../notification-discussion-stickied.js | 21 ++++++ .../components/post-discussion-stickied.js | 9 +++ extensions/sticky/less/sticky.less | 8 +++ ...02_24_000000_add_sticky_to_discussions.php | 31 +++++++++ .../src/DiscussionStickiedNotification.php | 39 +++++++++++ .../sticky/src/DiscussionStickiedPost.php | 66 ++++++++++++++++++ .../src/Events/DiscussionWasStickied.php | 27 ++++++++ .../src/Events/DiscussionWasUnstickied.php | 27 ++++++++ .../Handlers/DiscussionStickiedNotifier.php | 67 +++++++++++++++++++ .../sticky/src/Handlers/StickySaver.php | 34 ++++++++++ .../src/Handlers/StickySearchModifier.php | 37 ++++++++++ extensions/sticky/src/StickyGambit.php | 29 ++++++++ .../sticky/src/StickyServiceProvider.php | 34 ++++++++++ 22 files changed, 624 insertions(+) create mode 100644 extensions/sticky/.gitignore create mode 100644 extensions/sticky/LICENSE.txt create mode 100644 extensions/sticky/bootstrap.php create mode 100644 extensions/sticky/composer.json create mode 100644 extensions/sticky/extension.json create mode 100644 extensions/sticky/js/.gitignore create mode 100644 extensions/sticky/js/Gulpfile.js create mode 100644 extensions/sticky/js/bootstrap.js create mode 100644 extensions/sticky/js/package.json create mode 100644 extensions/sticky/js/src/components/notification-discussion-stickied.js create mode 100644 extensions/sticky/js/src/components/post-discussion-stickied.js create mode 100644 extensions/sticky/less/sticky.less create mode 100644 extensions/sticky/migrations/2015_02_24_000000_add_sticky_to_discussions.php create mode 100644 extensions/sticky/src/DiscussionStickiedNotification.php create mode 100755 extensions/sticky/src/DiscussionStickiedPost.php create mode 100644 extensions/sticky/src/Events/DiscussionWasStickied.php create mode 100644 extensions/sticky/src/Events/DiscussionWasUnstickied.php create mode 100755 extensions/sticky/src/Handlers/DiscussionStickiedNotifier.php create mode 100755 extensions/sticky/src/Handlers/StickySaver.php create mode 100755 extensions/sticky/src/Handlers/StickySearchModifier.php create mode 100644 extensions/sticky/src/StickyGambit.php create mode 100644 extensions/sticky/src/StickyServiceProvider.php diff --git a/extensions/sticky/.gitignore b/extensions/sticky/.gitignore new file mode 100644 index 000000000..a4f3b125e --- /dev/null +++ b/extensions/sticky/.gitignore @@ -0,0 +1,4 @@ +/vendor +composer.phar +.DS_Store +Thumbs.db diff --git a/extensions/sticky/LICENSE.txt b/extensions/sticky/LICENSE.txt new file mode 100644 index 000000000..aa1e5fb86 --- /dev/null +++ b/extensions/sticky/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/sticky/bootstrap.php b/extensions/sticky/bootstrap.php new file mode 100644 index 000000000..aaee6ed2c --- /dev/null +++ b/extensions/sticky/bootstrap.php @@ -0,0 +1,9 @@ +register('Flarum\Sticky\StickyServiceProvider'); diff --git a/extensions/sticky/composer.json b/extensions/sticky/composer.json new file mode 100644 index 000000000..278b2c61c --- /dev/null +++ b/extensions/sticky/composer.json @@ -0,0 +1,19 @@ +{ + "name": "flarum/sticky", + "description": "", + "authors": [ + { + "name": "Toby Zerner", + "email": "toby@flarum.org" + } + ], + "require": { + "php": ">=5.4.0" + }, + "autoload": { + "psr-4": { + "Flarum\\Sticky\\": "src/" + } + }, + "minimum-stability": "dev" +} diff --git a/extensions/sticky/extension.json b/extensions/sticky/extension.json new file mode 100644 index 000000000..2b9535fbc --- /dev/null +++ b/extensions/sticky/extension.json @@ -0,0 +1,15 @@ +{ + "name": "sticky", + "description": "Pin discussions to the top of the list.", + "version": "0.1.0", + "author": { + "name": "Toby Zerner", + "email": "toby@flarum.org", + "website": "http://tobyzerner.com" + }, + "license": "MIT", + "require": { + "php": ">=5.4.0", + "flarum": ">1.0.0" + } +} diff --git a/extensions/sticky/js/.gitignore b/extensions/sticky/js/.gitignore new file mode 100644 index 000000000..bae304483 --- /dev/null +++ b/extensions/sticky/js/.gitignore @@ -0,0 +1,4 @@ +bower_components +node_modules +mithril.js +dist diff --git a/extensions/sticky/js/Gulpfile.js b/extensions/sticky/js/Gulpfile.js new file mode 100644 index 000000000..09b1432e0 --- /dev/null +++ b/extensions/sticky/js/Gulpfile.js @@ -0,0 +1,45 @@ +var gulp = require('gulp'); +var livereload = require('gulp-livereload'); +var concat = require('gulp-concat'); +var argv = require('yargs').argv; +var uglify = require('gulp-uglify'); +var gulpif = require('gulp-if'); +var babel = require('gulp-babel'); +var cached = require('gulp-cached'); +var remember = require('gulp-remember'); +var merge = require('merge-stream'); +var streamqueue = require('streamqueue'); + +var staticFiles = [ + 'bootstrap.js' +]; +var moduleFiles = [ + 'src/**/*.js' +]; +var modulePrefix = 'sticky'; + +gulp.task('default', function() { + return streamqueue({objectMode: true}, + gulp.src(moduleFiles) + .pipe(cached('scripts')) + .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix })) + .pipe(remember('scripts')), + gulp.src(staticFiles) + .pipe(babel()) + ) + .pipe(concat('extension.js')) + .pipe(gulpif(argv.production, uglify())) + .pipe(gulp.dest('dist')) + .pipe(livereload()); +}); + +gulp.task('watch', ['default'], function () { + livereload.listen(); + var watcher = gulp.watch(moduleFiles.concat(staticFiles), ['default']); + watcher.on('change', function (event) { + if (event.type === 'deleted') { + delete cached.caches.scripts[event.path]; + remember.forget('scripts', event.path); + } + }); +}); diff --git a/extensions/sticky/js/bootstrap.js b/extensions/sticky/js/bootstrap.js new file mode 100644 index 000000000..0eac2c264 --- /dev/null +++ b/extensions/sticky/js/bootstrap.js @@ -0,0 +1,60 @@ +import { extend } from 'flarum/extension-utils'; +import Model from 'flarum/model'; +import Discussion from 'flarum/models/discussion'; +import DiscussionPage from 'flarum/components/discussion-page'; +import Badge from 'flarum/components/badge'; +import ActionButton from 'flarum/components/action-button'; +import SettingsPage from 'flarum/components/settings-page'; +import icon from 'flarum/helpers/icon'; +import app from 'flarum/app'; + +import PostDiscussionStickied from 'sticky/components/post-discussion-stickied'; +import NotificationDiscussionStickied from 'sticky/components/notification-discussion-stickied'; + +app.initializers.add('sticky', function() { + + // Register components. + app.postComponentRegistry['discussionStickied'] = PostDiscussionStickied; + app.notificationComponentRegistry['discussionStickied'] = NotificationDiscussionStickied; + + Discussion.prototype.isSticky = Model.prop('isSticky'); + + // Add a sticky badge to discussions. + extend(Discussion.prototype, 'badges', function(badges) { + if (this.isSticky()) { + badges.add('sticky', Badge.component({ + label: 'Sticky', + icon: 'thumb-tack', + className: 'badge-sticky', + })); + } + }); + + function toggleSticky() { + this.save({isSticky: !this.isSticky()}).then(discussion => { + if (app.current instanceof DiscussionPage) { + app.current.stream().sync(); + } + m.redraw(); + }); + } + + // Add a sticky control to discussions. + extend(Discussion.prototype, 'controls', function(items) { + if (this.canEdit()) { + items.add('sticky', ActionButton.component({ + label: this.isSticky() ? 'Unsticky' : 'Sticky', + icon: 'thumb-tack', + onclick: toggleSticky.bind(this) + }), {after: 'rename'}); + } + }); + + // Add a notification preference. + extend(SettingsPage.prototype, 'notificationTypes', function(items) { + items.add('discussionStickied', { + name: 'discussionStickied', + label: [icon('thumb-tack'), ' Someone stickies a discussion I started'] + }); + }); +}); diff --git a/extensions/sticky/js/package.json b/extensions/sticky/js/package.json new file mode 100644 index 000000000..b83b01cc5 --- /dev/null +++ b/extensions/sticky/js/package.json @@ -0,0 +1,18 @@ +{ + "name": "flarum-sticky", + "devDependencies": { + "gulp": "^3.8.11", + "gulp-babel": "^5.1.0", + "gulp-cached": "^1.0.4", + "gulp-concat": "^2.5.2", + "gulp-if": "^1.2.5", + "gulp-livereload": "^3.8.0", + "gulp-remember": "^0.3.0", + "gulp-uglify": "^1.2.0", + "merge-stream": "^0.1.7", + "yargs": "^3.7.2" + }, + "dependencies": { + "streamqueue": "^0.1.3" + } +} diff --git a/extensions/sticky/js/src/components/notification-discussion-stickied.js b/extensions/sticky/js/src/components/notification-discussion-stickied.js new file mode 100644 index 000000000..89bc169b8 --- /dev/null +++ b/extensions/sticky/js/src/components/notification-discussion-stickied.js @@ -0,0 +1,21 @@ +import Notification from 'flarum/components/notification'; +import username from 'flarum/helpers/username'; + +export default class NotificationDiscussionStickied extends Notification { + view() { + var notification = this.props.notification; + var discussion = notification.subject(); + + return super.view({ + href: app.route('discussion.near', { + id: discussion.id(), + slug: discussion.slug(), + near: notification.content().postNumber + }), + config: m.route, + title: discussion.title(), + icon: 'thumb-tack', + content: ['Stickied by ', username(notification.sender())] + }); + } +} diff --git a/extensions/sticky/js/src/components/post-discussion-stickied.js b/extensions/sticky/js/src/components/post-discussion-stickied.js new file mode 100644 index 000000000..beb75e70f --- /dev/null +++ b/extensions/sticky/js/src/components/post-discussion-stickied.js @@ -0,0 +1,9 @@ +import PostActivity from 'flarum/components/post-activity'; + +export default class PostDiscussionStickied extends PostActivity { + view() { + var post = this.props.post; + + return super.view('thumb-tack', [post.content().sticky ? 'stickied' : 'unstickied', ' the discussion.']); + } +} diff --git a/extensions/sticky/less/sticky.less b/extensions/sticky/less/sticky.less new file mode 100644 index 000000000..d8e24bf9c --- /dev/null +++ b/extensions/sticky/less/sticky.less @@ -0,0 +1,8 @@ +.badge-sticky { + background: #d13e32; +} +.post-discussion-stickied { + & .post-icon, & .post-activity-info, & .post-activity-info a { + color: #d13e32; + } +} diff --git a/extensions/sticky/migrations/2015_02_24_000000_add_sticky_to_discussions.php b/extensions/sticky/migrations/2015_02_24_000000_add_sticky_to_discussions.php new file mode 100644 index 000000000..1a0884c53 --- /dev/null +++ b/extensions/sticky/migrations/2015_02_24_000000_add_sticky_to_discussions.php @@ -0,0 +1,31 @@ +boolean('is_sticky')->default(0); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('discussions', function (Blueprint $table) { + $table->dropColumn('is_sticky'); + }); + } +} diff --git a/extensions/sticky/src/DiscussionStickiedNotification.php b/extensions/sticky/src/DiscussionStickiedNotification.php new file mode 100644 index 000000000..40e407a50 --- /dev/null +++ b/extensions/sticky/src/DiscussionStickiedNotification.php @@ -0,0 +1,39 @@ +post = $post; + + parent::__construct($recipient, $sender); + } + + public function getSubject() + { + return $this->post->discussion; + } + + public function getAlertData() + { + return [ + 'postNumber' => $this->post->number + ]; + } + + public static function getType() + { + return 'discussionStickied'; + } + + public static function getSubjectModel() + { + return 'Flarum\Core\Models\Discussion'; + } +} diff --git a/extensions/sticky/src/DiscussionStickiedPost.php b/extensions/sticky/src/DiscussionStickiedPost.php new file mode 100755 index 000000000..08d80b33a --- /dev/null +++ b/extensions/sticky/src/DiscussionStickiedPost.php @@ -0,0 +1,66 @@ +user_id === $previous->user_id) { + if ($previous->content['sticky'] != $this->content['sticky']) { + return; + } + + $previous->content = $this->content; + return $previous; + } + + return $this; + } + + /** + * Create a new instance in reply to a discussion. + * + * @param integer $discussionId + * @param integer $userId + * @param boolean $isSticky + * @return static + */ + public static function reply($discussionId, $userId, $isSticky) + { + $post = new static; + + $post->content = static::buildContent($isSticky); + $post->time = time(); + $post->discussion_id = $discussionId; + $post->user_id = $userId; + + return $post; + } + + /** + * Build the content attribute. + * + * @param boolean $isSticky Whether or not the discussion is stickied. + * @return array + */ + public static function buildContent($isSticky) + { + return ['sticky' => (bool) $isSticky]; + } +} diff --git a/extensions/sticky/src/Events/DiscussionWasStickied.php b/extensions/sticky/src/Events/DiscussionWasStickied.php new file mode 100644 index 000000000..31432f4aa --- /dev/null +++ b/extensions/sticky/src/Events/DiscussionWasStickied.php @@ -0,0 +1,27 @@ +discussion = $discussion; + $this->user = $user; + } +} diff --git a/extensions/sticky/src/Events/DiscussionWasUnstickied.php b/extensions/sticky/src/Events/DiscussionWasUnstickied.php new file mode 100644 index 000000000..24e3fc2fd --- /dev/null +++ b/extensions/sticky/src/Events/DiscussionWasUnstickied.php @@ -0,0 +1,27 @@ +discussion = $discussion; + $this->user = $user; + } +} diff --git a/extensions/sticky/src/Handlers/DiscussionStickiedNotifier.php b/extensions/sticky/src/Handlers/DiscussionStickiedNotifier.php new file mode 100755 index 000000000..fdfa28aa1 --- /dev/null +++ b/extensions/sticky/src/Handlers/DiscussionStickiedNotifier.php @@ -0,0 +1,67 @@ +notifier = $notifier; + } + + /** + * Register the listeners for the subscriber. + * + * @param \Illuminate\Contracts\Events\Dispatcher $events + */ + public function subscribe(Dispatcher $events) + { + $events->listen('Flarum\Sticky\Events\DiscussionWasStickied', __CLASS__.'@whenDiscussionWasStickied'); + $events->listen('Flarum\Sticky\Events\DiscussionWasUnstickied', __CLASS__.'@whenDiscussionWasUnstickied'); + } + + public function whenDiscussionWasStickied(DiscussionWasStickied $event) + { + $post = $this->createPost($event->discussion->id, $event->user->id, true); + + $post = $event->discussion->addPost($post); + + if ($event->discussion->start_user_id !== $event->user->id) { + $this->sendNotification($post); + } + } + + public function whenDiscussionWasUnstickied(DiscussionWasUnstickied $event) + { + $post = $this->createPost($event->discussion->id, $event->user->id, false); + + $event->discussion->addPost($post); + } + + protected function createPost($discussionId, $userId, $isSticky) + { + return DiscussionStickiedPost::reply( + $discussionId, + $userId, + $isSticky + ); + } + + protected function sendNotification(DiscussionStickiedPost $post) + { + $notification = new DiscussionStickiedNotification( + $post->discussion->startUser, + $post->user, + $post + ); + + $this->notifier->send($notification); + } +} diff --git a/extensions/sticky/src/Handlers/StickySaver.php b/extensions/sticky/src/Handlers/StickySaver.php new file mode 100755 index 000000000..cffbb6208 --- /dev/null +++ b/extensions/sticky/src/Handlers/StickySaver.php @@ -0,0 +1,34 @@ +listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); + } + + public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) + { + if (isset($event->command->data['isSticky'])) { + $isSticky = (bool) $event->command->data['isSticky']; + $discussion = $event->discussion; + $user = $event->command->user; + + if ((bool) $discussion->is_sticky === $isSticky) { + return; + } + + $discussion->is_sticky = $isSticky; + + $discussion->raise( + $discussion->is_sticky + ? new DiscussionWasStickied($discussion, $user) + : new DiscussionWasUnstickied($discussion, $user) + ); + } + } +} diff --git a/extensions/sticky/src/Handlers/StickySearchModifier.php b/extensions/sticky/src/Handlers/StickySearchModifier.php new file mode 100755 index 000000000..05592c5d8 --- /dev/null +++ b/extensions/sticky/src/Handlers/StickySearchModifier.php @@ -0,0 +1,37 @@ +listen('Flarum\Core\Events\DiscussionSearchWillBePerformed', __CLASS__.'@reorderSearch'); + } + + public function reorderSearch(DiscussionSearchWillBePerformed $event) + { + if ($event->criteria->sort === null) { + $query = $event->searcher->query(); + + foreach ($event->searcher->getActiveGambits() as $gambit) { + if ($gambit instanceof CategoryGambit) { + array_unshift($query->orders, ['column' => 'is_sticky', 'direction' => 'desc']); + return; + } + } + + $query->leftJoin('users_discussions', function ($join) use ($event) { + $join->on('users_discussions.discussion_id', '=', 'discussions.id') + ->where('discussions.is_sticky', '=', true) + ->where('users_discussions.user_id', '=', $event->criteria->user->id); + }); + // might be quicker to do a subquery in the order clause than a join? + array_unshift( + $query->orders, + ['type' => 'raw', 'sql' => '(is_sticky AND (users_discussions.read_number IS NULL OR discussions.last_post_number > users_discussions.read_number)) desc'] + ); + } + } +} diff --git a/extensions/sticky/src/StickyGambit.php b/extensions/sticky/src/StickyGambit.php new file mode 100644 index 000000000..84c3c2f7d --- /dev/null +++ b/extensions/sticky/src/StickyGambit.php @@ -0,0 +1,29 @@ +query()->where('is_sticky', $sticky); + } +} diff --git a/extensions/sticky/src/StickyServiceProvider.php b/extensions/sticky/src/StickyServiceProvider.php new file mode 100644 index 000000000..2dd79f6e7 --- /dev/null +++ b/extensions/sticky/src/StickyServiceProvider.php @@ -0,0 +1,34 @@ +subscribe('Flarum\Sticky\Handlers\StickySaver'); + $events->subscribe('Flarum\Sticky\Handlers\StickySearchModifier'); + $events->subscribe('Flarum\Sticky\Handlers\DiscussionStickiedNotifier'); + + $this->forumAssets([ + __DIR__.'/../js/dist/extension.js', + __DIR__.'/../less/sticky.less' + ]); + + $this->postType('Flarum\Sticky\DiscussionStickiedPost'); + + $this->serializeAttributes('Flarum\Api\Serializers\DiscussionSerializer', function (&$attributes, $model) { + $attributes['isSticky'] = (bool) $model->is_sticky; + }); + + $this->discussionGambit('Flarum\Sticky\StickyGambit'); + + $this->notificationType('Flarum\Sticky\DiscussionStickiedNotification', ['alert' => true]); + } +}