diff --git a/app/Events/ReplyWasCreated.php b/app/Events/ReplyWasCreated.php
new file mode 100644
index 000000000..c091f63b6
--- /dev/null
+++ b/app/Events/ReplyWasCreated.php
@@ -0,0 +1,21 @@
+reply = $reply;
+ }
+}
diff --git a/app/Exceptions/CannotCreateUser.php b/app/Exceptions/CannotCreateUser.php
index c97f5486a..6d352c3e7 100644
--- a/app/Exceptions/CannotCreateUser.php
+++ b/app/Exceptions/CannotCreateUser.php
@@ -4,7 +4,7 @@
use Exception;
-class CannotCreateUser extends Exception
+final class CannotCreateUser extends Exception
{
public static function duplicateEmailAddress(string $emailAddress): self
{
diff --git a/app/Exceptions/CouldNotMarkReplyAsSolution.php b/app/Exceptions/CouldNotMarkReplyAsSolution.php
index 7d0ded4f8..0def890f5 100644
--- a/app/Exceptions/CouldNotMarkReplyAsSolution.php
+++ b/app/Exceptions/CouldNotMarkReplyAsSolution.php
@@ -5,7 +5,7 @@
use Exception;
use App\Models\Reply;
-class CouldNotMarkReplyAsSolution extends Exception
+final class CouldNotMarkReplyAsSolution extends Exception
{
public static function replyAbleIsNotAThread(Reply $reply): self
{
diff --git a/app/Helpers/HasUuid.php b/app/Helpers/HasUuid.php
new file mode 100644
index 000000000..5d8e44cb6
--- /dev/null
+++ b/app/Helpers/HasUuid.php
@@ -0,0 +1,29 @@
+uuid);
+ }
+
+ public function getKeyName()
+ {
+ return 'uuid';
+ }
+
+ public function getIncrementing()
+ {
+ return false;
+ }
+
+ public static function findByUuidOrFail(UuidInterface $uuid): self
+ {
+ return static::where('uuid', $uuid->toString())->firstOrFail();
+ }
+}
diff --git a/app/Helpers/ProvidesSubscriptions.php b/app/Helpers/ProvidesSubscriptions.php
new file mode 100644
index 000000000..911efcecd
--- /dev/null
+++ b/app/Helpers/ProvidesSubscriptions.php
@@ -0,0 +1,30 @@
+subscriptionsRelation;
+ }
+
+ public function subscriptionsRelation(): MorphMany
+ {
+ return $this->morphMany(Subscription::class, 'subscriptionable');
+ }
+
+ public function hasSubscriber(User $user): bool
+ {
+ return $this->subscriptionsRelation()
+ ->where('user_id', $user->id())
+ ->exists();
+ }
+}
diff --git a/app/Http/Controllers/Auth/GithubController.php b/app/Http/Controllers/Auth/GithubController.php
index 3d71ebe4f..f455caa46 100644
--- a/app/Http/Controllers/Auth/GithubController.php
+++ b/app/Http/Controllers/Auth/GithubController.php
@@ -29,7 +29,7 @@ public function redirectToProvider()
public function handleProviderCallback()
{
try {
- $socialiteUser = Socialite::driver('github')->user();
+ $socialiteUser = $this->getSocialiteUser();
} catch (InvalidStateException $exception) {
$this->error('errors.github_invalid_state');
@@ -38,16 +38,21 @@ public function handleProviderCallback()
try {
$user = User::findByGithubId($socialiteUser->getId());
-
- return $this->userFound($user, $socialiteUser);
} catch (ModelNotFoundException $exception) {
- return $this->userNotFound(new GithubUser($socialiteUser->user));
+ return $this->userNotFound(new GithubUser($socialiteUser->getRaw()));
}
+
+ return $this->userFound($user, $socialiteUser);
+ }
+
+ private function getSocialiteUser(): SocialiteUser
+ {
+ return Socialite::driver('github')->user();
}
private function userFound(User $user, SocialiteUser $socialiteUser): RedirectResponse
{
- $this->dispatchNow(new UpdateProfile($user, ['github_username' => $socialiteUser->nickname]));
+ $this->dispatchNow(new UpdateProfile($user, ['github_username' => $socialiteUser->getNickname()]));
Auth::login($user);
diff --git a/app/Http/Controllers/Forum/ThreadsController.php b/app/Http/Controllers/Forum/ThreadsController.php
index 9fad663c5..9c074f9b8 100644
--- a/app/Http/Controllers/Forum/ThreadsController.php
+++ b/app/Http/Controllers/Forum/ThreadsController.php
@@ -8,13 +8,16 @@
use App\Jobs\CreateThread;
use App\Jobs\DeleteThread;
use App\Jobs\UpdateThread;
+use Illuminate\Http\Request;
use App\Policies\ThreadPolicy;
use App\Queries\SearchThreads;
use App\Jobs\MarkThreadSolution;
use App\Jobs\UnmarkThreadSolution;
use App\Http\Controllers\Controller;
use App\Http\Requests\ThreadRequest;
+use App\Jobs\SubscribeToSubscriptionAble;
use Illuminate\Auth\Middleware\Authenticate;
+use App\Jobs\UnsubscribeFromSubscriptionAble;
use App\Http\Middleware\RedirectIfUnconfirmed;
class ThreadsController extends Controller
@@ -97,4 +100,26 @@ public function unmarkSolution(Thread $thread)
return redirect()->route('thread', $thread->slug());
}
+
+ public function subscribe(Request $request, Thread $thread)
+ {
+ $this->authorize(ThreadPolicy::SUBSCRIBE, $thread);
+
+ $this->dispatchNow(new SubscribeToSubscriptionAble($request->user(), $thread));
+
+ $this->success("You're now subscribed to this thread.");
+
+ return redirect()->route('thread', $thread->slug());
+ }
+
+ public function unsubscribe(Request $request, Thread $thread)
+ {
+ $this->authorize(ThreadPolicy::UNSUBSCRIBE, $thread);
+
+ $this->dispatchNow(new UnsubscribeFromSubscriptionAble($request->user(), $thread));
+
+ $this->success("You're now unsubscribed from this thread.");
+
+ return redirect()->route('thread', $thread->slug());
+ }
}
diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php
new file mode 100644
index 000000000..a0f49726d
--- /dev/null
+++ b/app/Http/Controllers/SubscriptionController.php
@@ -0,0 +1,20 @@
+subscriptionAble();
+
+ $this->dispatch(new UnsubscribeFromSubscriptionAble($subscription->user(), $thread));
+
+ $this->success("You're now unsubscribed from this thread.");
+
+ return redirect()->route('thread', $thread->slug());
+ }
+}
diff --git a/app/Jobs/CreateReply.php b/app/Jobs/CreateReply.php
index ef60b3c5f..1d8a664eb 100644
--- a/app/Jobs/CreateReply.php
+++ b/app/Jobs/CreateReply.php
@@ -4,7 +4,11 @@
use App\User;
use App\Models\Reply;
+use Ramsey\Uuid\Uuid;
use App\Models\ReplyAble;
+use App\Models\Subscription;
+use App\Events\ReplyWasCreated;
+use App\Models\SubscriptionAble;
use App\Http\Requests\CreateReplyRequest;
final class CreateReply
@@ -49,6 +53,17 @@ public function handle(): Reply
$reply->to($this->replyAble);
$reply->save();
+ event(new ReplyWasCreated($reply));
+
+ if ($this->replyAble instanceof SubscriptionAble && ! $this->replyAble->hasSubscriber($this->author)) {
+ $subscription = new Subscription();
+ $subscription->uuid = Uuid::uuid4()->toString();
+ $subscription->userRelation()->associate($this->author);
+ $subscription->subscriptionAbleRelation()->associate($this->replyAble);
+
+ $this->replyAble->subscriptionsRelation()->save($subscription);
+ }
+
return $reply;
}
}
diff --git a/app/Jobs/CreateThread.php b/app/Jobs/CreateThread.php
index e4e98835e..3abe5dd42 100644
--- a/app/Jobs/CreateThread.php
+++ b/app/Jobs/CreateThread.php
@@ -3,7 +3,9 @@
namespace App\Jobs;
use App\User;
+use Ramsey\Uuid\Uuid;
use App\Models\Thread;
+use App\Models\Subscription;
use App\Http\Requests\ThreadRequest;
final class CreateThread
@@ -65,6 +67,14 @@ public function handle(): Thread
$thread->syncTags($this->tags);
$thread->save();
+ // Subscribe author to the thread.
+ $subscription = new Subscription();
+ $subscription->uuid = Uuid::uuid4()->toString();
+ $subscription->userRelation()->associate($this->author);
+ $subscription->subscriptionAbleRelation()->associate($thread);
+
+ $thread->subscriptionsRelation()->save($subscription);
+
return $thread;
}
}
diff --git a/app/Jobs/SendEmailConfirmation.php b/app/Jobs/SendEmailConfirmation.php
index d674d3457..e4d04d0d4 100644
--- a/app/Jobs/SendEmailConfirmation.php
+++ b/app/Jobs/SendEmailConfirmation.php
@@ -3,7 +3,7 @@
namespace App\Jobs;
use App\User;
-use App\Mail\EmailConfirmation;
+use App\Mail\EmailConfirmationEmail;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Queue\SerializesModels;
@@ -24,6 +24,6 @@ public function __construct(User $user)
public function handle(Mailer $mailer)
{
$mailer->to($this->user->emailAddress())
- ->send(new EmailConfirmation($this->user));
+ ->send(new EmailConfirmationEmail($this->user));
}
}
diff --git a/app/Jobs/SubscribeToSubscriptionAble.php b/app/Jobs/SubscribeToSubscriptionAble.php
new file mode 100644
index 000000000..c57ffe79f
--- /dev/null
+++ b/app/Jobs/SubscribeToSubscriptionAble.php
@@ -0,0 +1,35 @@
+user = $user;
+ $this->subscriptionAble = $subscriptionAble;
+ }
+
+ public function handle()
+ {
+ $subscription = new Subscription();
+ $subscription->uuid = Uuid::uuid4()->toString();
+ $subscription->userRelation()->associate($this->user);
+ $this->subscriptionAble->subscriptionsRelation()->save($subscription);
+ }
+}
diff --git a/app/Jobs/UnsubscribeFromSubscriptionAble.php b/app/Jobs/UnsubscribeFromSubscriptionAble.php
new file mode 100644
index 000000000..1f4fc5422
--- /dev/null
+++ b/app/Jobs/UnsubscribeFromSubscriptionAble.php
@@ -0,0 +1,32 @@
+user = $user;
+ $this->subscriptionAble = $subscriptionAble;
+ }
+
+ public function handle()
+ {
+ $this->subscriptionAble->subscriptionsRelation()
+ ->where('user_id', $this->user->id())
+ ->delete();
+ }
+}
diff --git a/app/Listeners/SendNewReplyNotification.php b/app/Listeners/SendNewReplyNotification.php
new file mode 100644
index 000000000..9779d3daf
--- /dev/null
+++ b/app/Listeners/SendNewReplyNotification.php
@@ -0,0 +1,24 @@
+reply->replyAble()->subscriptions() as $subscription) {
+ if ($this->replyAuthorDoesNotMatchSubscriber($event->reply->author(), $subscription)) {
+ $subscription->user()->notify(new NewReplyNotification($event->reply, $subscription));
+ }
+ }
+ }
+
+ private function replyAuthorDoesNotMatchSubscriber(User $author, $subscription): bool
+ {
+ return ! $author->matches($subscription->user());
+ }
+}
diff --git a/app/Mail/EmailConfirmation.php b/app/Mail/EmailConfirmationEmail.php
similarity index 89%
rename from app/Mail/EmailConfirmation.php
rename to app/Mail/EmailConfirmationEmail.php
index 24a0c7bcc..d1910d69a 100644
--- a/app/Mail/EmailConfirmation.php
+++ b/app/Mail/EmailConfirmationEmail.php
@@ -6,7 +6,7 @@
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
-final class EmailConfirmation extends Mailable
+final class EmailConfirmationEmail extends Mailable
{
use SerializesModels;
diff --git a/app/Mail/NewReplyEmail.php b/app/Mail/NewReplyEmail.php
new file mode 100644
index 000000000..14e111551
--- /dev/null
+++ b/app/Mail/NewReplyEmail.php
@@ -0,0 +1,37 @@
+reply = $reply;
+ $this->subscription = $subscription;
+ }
+
+ public function build()
+ {
+ return $this->subject("Re: {$this->reply->replyAble()->subject()}")
+ ->markdown('emails.new_reply');
+ }
+}
diff --git a/app/Models/ReplyAble.php b/app/Models/ReplyAble.php
index ecf459c18..1a81ea369 100644
--- a/app/Models/ReplyAble.php
+++ b/app/Models/ReplyAble.php
@@ -9,6 +9,8 @@
*/
interface ReplyAble
{
+ public function subject(): string;
+
/**
* @return \App\Models\Reply[]
*/
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
new file mode 100644
index 000000000..afe57eee9
--- /dev/null
+++ b/app/Models/Subscription.php
@@ -0,0 +1,39 @@
+userRelation;
+ }
+
+ public function userRelation(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+
+ public function subscriptionAble(): SubscriptionAble
+ {
+ return $this->subscriptionAbleRelation;
+ }
+
+ public function subscriptionAbleRelation(): MorphTo
+ {
+ return $this->morphTo('subscriptionable');
+ }
+}
diff --git a/app/Models/SubscriptionAble.php b/app/Models/SubscriptionAble.php
new file mode 100644
index 000000000..b423d63f4
--- /dev/null
+++ b/app/Models/SubscriptionAble.php
@@ -0,0 +1,18 @@
+reply = $reply;
+ $this->subscription = $subscription;
+ }
+
+ public function via(User $user)
+ {
+ return ['mail'];
+ }
+
+ public function toMail(User $user)
+ {
+ return (new NewReplyEmail($this->reply, $this->subscription))
+ ->to($user->emailAddress(), $user->name());
+ }
+
+ public function toArray(User $user)
+ {
+ return [
+ //
+ ];
+ }
+}
diff --git a/app/Policies/ThreadPolicy.php b/app/Policies/ThreadPolicy.php
index 93caa26ae..18d19cf76 100644
--- a/app/Policies/ThreadPolicy.php
+++ b/app/Policies/ThreadPolicy.php
@@ -9,20 +9,26 @@ class ThreadPolicy
{
const UPDATE = 'update';
const DELETE = 'delete';
+ const SUBSCRIBE = 'subscribe';
+ const UNSUBSCRIBE = 'unsubscribe';
- /**
- * Determine if the given thread can be updated by the user.
- */
public function update(User $user, Thread $thread): bool
{
return $thread->isAuthoredBy($user) || $user->isModerator() || $user->isAdmin();
}
- /**
- * Determine if the given thread can be deleted by the user.
- */
public function delete(User $user, Thread $thread): bool
{
return $thread->isAuthoredBy($user) || $user->isModerator() || $user->isAdmin();
}
+
+ public function subscribe(User $user, Thread $thread): bool
+ {
+ return ! $thread->hasSubscriber($user);
+ }
+
+ public function unsubscribe(User $user, Thread $thread): bool
+ {
+ return $thread->hasSubscriber($user);
+ }
}
diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php
new file mode 100644
index 000000000..3dfb5e070
--- /dev/null
+++ b/app/Providers/EventServiceProvider.php
@@ -0,0 +1,21 @@
+ [
+ SendNewReplyNotification::class,
+ ],
+ ];
+}
diff --git a/composer.json b/composer.json
index 2f49d439b..57574637e 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
"laravelcollective/html": "5.5.*",
"lasserafn/php-initial-avatar-generator": "^2.0",
"league/commonmark": "^0.15.2",
+ "ramsey/uuid": "^3.7",
"roave/security-advisories": "dev-master",
"spatie/laravel-robots-middleware": "^1.0",
"tijsverkoyen/akismet": "^1.1"
diff --git a/composer.lock b/composer.lock
index 40534b1c6..92b23e404 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
- "content-hash": "282ec772f39fae2f400f1c534bcc2b33",
+ "content-hash": "1702397b8fdfade09b0a7f5f439c384b",
"packages": [
{
"name": "bugsnag/bugsnag",
@@ -1355,16 +1355,16 @@
},
{
"name": "laravel/framework",
- "version": "v5.5.14",
+ "version": "v5.5.24",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "26c700eb79e5bb55b59df2c495c9c71f16f43302"
+ "reference": "06135405bb1f736dac5e9529ed1541fc446c9c0f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/26c700eb79e5bb55b59df2c495c9c71f16f43302",
- "reference": "26c700eb79e5bb55b59df2c495c9c71f16f43302",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/06135405bb1f736dac5e9529ed1541fc446c9c0f",
+ "reference": "06135405bb1f736dac5e9529ed1541fc446c9c0f",
"shasum": ""
},
"require": {
@@ -1405,7 +1405,6 @@
"illuminate/database": "self.version",
"illuminate/encryption": "self.version",
"illuminate/events": "self.version",
- "illuminate/exception": "self.version",
"illuminate/filesystem": "self.version",
"illuminate/hashing": "self.version",
"illuminate/http": "self.version",
@@ -1439,6 +1438,8 @@
"suggest": {
"aws/aws-sdk-php": "Required to use the SQS queue driver and SES mail driver (~3.0).",
"doctrine/dbal": "Required to rename columns and drop SQLite columns (~2.5).",
+ "ext-pcntl": "Required to use all features of the queue worker.",
+ "ext-posix": "Required to use all features of the queue worker.",
"fzaninotto/faker": "Required to use the eloquent factory builder (~1.4).",
"guzzlehttp/guzzle": "Required to use the Mailgun and Mandrill mail drivers and the ping methods on schedules (~6.0).",
"laravel/tinker": "Required to use the tinker console command (~1.0).",
@@ -1447,7 +1448,7 @@
"nexmo/client": "Required to use the Nexmo transport (~1.0).",
"pda/pheanstalk": "Required to use the beanstalk queue driver (~3.0).",
"predis/predis": "Required to use the redis cache and queue drivers (~1.0).",
- "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (~2.0).",
+ "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (~3.0).",
"symfony/css-selector": "Required to use some of the crawler integration testing tools (~3.3).",
"symfony/dom-crawler": "Required to use most of the crawler integration testing tools (~3.3).",
"symfony/psr-http-message-bridge": "Required to psr7 bridging features (~1.0)."
@@ -1483,7 +1484,7 @@
"framework",
"laravel"
],
- "time": "2017-10-03T17:41:03+00:00"
+ "time": "2017-12-07T01:28:21+00:00"
},
{
"name": "laravel/socialite",
@@ -2574,16 +2575,16 @@
},
{
"name": "ramsey/uuid",
- "version": "3.7.1",
+ "version": "3.7.3",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
- "reference": "45cffe822057a09e05f7bd09ec5fb88eeecd2334"
+ "reference": "44abcdad877d9a46685a3a4d221e3b2c4b87cb76"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/45cffe822057a09e05f7bd09ec5fb88eeecd2334",
- "reference": "45cffe822057a09e05f7bd09ec5fb88eeecd2334",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/44abcdad877d9a46685a3a4d221e3b2c4b87cb76",
+ "reference": "44abcdad877d9a46685a3a4d221e3b2c4b87cb76",
"shasum": ""
},
"require": {
@@ -2594,17 +2595,15 @@
"rhumsaa/uuid": "self.version"
},
"require-dev": {
- "apigen/apigen": "^4.1",
- "codeception/aspect-mock": "^1.0 | ^2.0",
+ "codeception/aspect-mock": "^1.0 | ~2.0.0",
"doctrine/annotations": "~1.2.0",
"goaop/framework": "1.0.0-alpha.2 | ^1.0 | ^2.1",
"ircmaxell/random-lib": "^1.1",
"jakub-onderka/php-parallel-lint": "^0.9.0",
- "mockery/mockery": "^0.9.4",
+ "mockery/mockery": "^0.9.9",
"moontoast/math": "^1.1",
"php-mock/php-mock-phpunit": "^0.3|^1.1",
- "phpunit/phpunit": "^4.7|>=5.0 <5.4",
- "satooshi/php-coveralls": "^0.6.1",
+ "phpunit/phpunit": "^4.7|^5.0",
"squizlabs/php_codesniffer": "^2.3"
},
"suggest": {
@@ -2652,7 +2651,7 @@
"identifier",
"uuid"
],
- "time": "2017-09-22T20:46:04+00:00"
+ "time": "2018-01-20T00:28:24+00:00"
},
{
"name": "roave/security-advisories",
diff --git a/config/app.php b/config/app.php
index 2e2f02849..2e0920e34 100644
--- a/config/app.php
+++ b/config/app.php
@@ -173,6 +173,7 @@
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
+ App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Markdown\MarkdownServiceProvider::class,
App\Spam\SpamServiceProvider::class,
diff --git a/database/factories/SubscriptionFactory.php b/database/factories/SubscriptionFactory.php
new file mode 100644
index 000000000..39294f29a
--- /dev/null
+++ b/database/factories/SubscriptionFactory.php
@@ -0,0 +1,15 @@
+define(Subscription::class, function (Faker\Generator $faker, array $attributes = []) {
+ return [
+ 'uuid' => Uuid::uuid4()->toString(),
+ 'user_id' => $attributes['user_id'] ?? factory(User::class)->create()->id(),
+ 'subscriptionable_id' => $attributes['subscriptionable_id'] ?? factory(Thread::class)->create()->id(),
+ 'subscriptionable_type' => Thread::TABLE,
+ ];
+});
diff --git a/database/migrations/2017_10_18_193001_create_subscriptions_table.php b/database/migrations/2017_10_18_193001_create_subscriptions_table.php
new file mode 100644
index 000000000..e1f874854
--- /dev/null
+++ b/database/migrations/2017_10_18_193001_create_subscriptions_table.php
@@ -0,0 +1,27 @@
+uuid('uuid');
+ $table->primary('uuid');
+ $table->integer('user_id')->unsigned();
+ $table->integer('subscriptionable_id');
+ $table->string('subscriptionable_type')->default('');
+ $table->timestamps();
+ });
+
+ Schema::table('subscriptions', function (Blueprint $table) {
+ $table->index(['user_id', 'uuid']);
+ $table->foreign('user_id')
+ ->references('id')->on('users')
+ ->onDelete('CASCADE');
+ });
+ }
+}
diff --git a/resources/views/emails/new_reply.blade.php b/resources/views/emails/new_reply.blade.php
new file mode 100644
index 000000000..fe3108d3c
--- /dev/null
+++ b/resources/views/emails/new_reply.blade.php
@@ -0,0 +1,13 @@
+@component('mail::message')
+
+**{{ $reply->author()->name() }}** has replied to this thread.
+
+@component('mail::panel')
+@md($reply->body())
+@endcomponent
+
+You are receiving this because you are subscribed to this thread.
+[View it on Laravel.io]({{ route('thread', $reply->replyAble()->slug()) }}),
+or [unsubscribe]({{ route('subscriptions.unsubscribe', $subscription->uuid()->toString()) }}).
+
+@endcomponent
diff --git a/resources/views/forum/threads/show.blade.php b/resources/views/forum/threads/show.blade.php
index c9f83b0c4..586a7feb8 100644
--- a/resources/views/forum/threads/show.blade.php
+++ b/resources/views/forum/threads/show.blade.php
@@ -10,11 +10,25 @@