diff --git a/README.md b/README.md index 980ec2d..22014e2 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ __These keys must be safely stored and should not change.__ ## Usage -Now you can use the channel in your `via()` method inside the notification and send a web push notification: +Now you can use the channel in your `via()` method inside the notification and send a generic web push notification: ```php use Illuminate\Notifications\Notification; @@ -107,6 +107,49 @@ class AccountApproved extends Notification } ``` +### Declarative Web Push messages + +> **Note:** The specification for Declarative Web Push messages is still evolving and may change in the future. Browser support for this functionality is currently limited and may vary across platforms. + +This package also supports [Declarative Web Push messages](https://www.w3.org/TR/push-api/#declarative-push-message), which aim to reduce the complexity of using push on the web in general and address some challenges of generic web push notifications like privacy concerns & battery life on mobile by making a client-side service worker optional while remaining fully backwards compatible: + +```php +use Illuminate\Notifications\Notification; +use NotificationChannels\WebPush\DeclarativeWebPushMessage; +use NotificationChannels\WebPush\WebPushChannel; + +class AccountApproved extends Notification +{ + public function via($notifiable) + { + return [WebPushChannel::class]; + } + + public function toWebPush($notifiable, $notification) + { + return (new DeclarativeWebPushMessage) + ->title('Approved!') + ->icon('/approved-icon.png') + ->body('Your account was approved!') + ->action('View account', 'view_account', 'https://myapp.com/accounts') + ->navigate('https://myapp.com'); + // ->data(['id' => $notification->id]) + // ->badge() + // ->dir() + // ->image() + // ->lang() + // ->mutable() + // ->renotify() + // ->requireInteraction() + // ->silent() + // ->tag() + // ->timestamp() + // ->vibrate() + // ->options(['TTL' => 1000, 'contentType' => 'application/json']) + } +} +``` + You can find the available options [here](https://github.com/web-push-libs/web-push-php#notifications-and-default-options). ### Save/Update Subscriptions diff --git a/composer.json b/composer.json index 01c6115..f6fc0ac 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "php": "^8.2", "illuminate/notifications": "^11.0|^12.0", "illuminate/support": "^11.0|^12.0", - "minishlink/web-push": "^9.0" + "minishlink/web-push": "^9.0.3" }, "require-dev": { "larastan/larastan": "^3.1", diff --git a/src/DeclarativeWebPushMessage.php b/src/DeclarativeWebPushMessage.php new file mode 100644 index 0000000..8bedc32 --- /dev/null +++ b/src/DeclarativeWebPushMessage.php @@ -0,0 +1,305 @@ + + * + * @link https://www.w3.org/TR/push-api/#members + */ +class DeclarativeWebPushMessage implements WebPushMessageInterface +{ + protected string $title; + + /** + * @var array + */ + protected array $actions = []; + + protected string $badge; + + protected string $body; + + protected string $dir; + + protected string $icon; + + protected string $image; + + protected string $lang; + + protected bool $mutable; + + protected string $navigate; + + protected bool $renotify; + + protected bool $requireInteraction; + + protected bool $silent; + + protected string $tag; + + protected int $timestamp; + + /** + * @var array + */ + protected array $vibrate; + + protected mixed $data; + + /** + * @var array + */ + protected array $options = [ + 'contentType' => 'application/json', + ]; + + /** + * Set the notification title. + * + * @return $this + */ + public function title(string $value): static + { + $this->title = $value; + + return $this; + } + + /** + * Add a notification action. + * + * @return $this + */ + public function action(string $title, string $action, string $navigate, ?string $icon = null): static + { + $this->actions[] = array_filter(['title' => $title, 'action' => $action, 'navigate' => $navigate, 'icon' => $icon]); + + return $this; + } + + /** + * Set the notification badge. + * + * @return $this + */ + public function badge(string $value): static + { + $this->badge = $value; + + return $this; + } + + /** + * Set the notification body. + * + * @return $this + */ + public function body(string $value): static + { + $this->body = $value; + + return $this; + } + + /** + * Set the notification direction. + * + * @return $this + */ + public function dir(string $value): static + { + $this->dir = $value; + + return $this; + } + + /** + * Set the notification icon url. + * + * @return $this + */ + public function icon(string $value): static + { + $this->icon = $value; + + return $this; + } + + /** + * Set the notification image url. + * + * @return $this + */ + public function image(string $value): static + { + $this->image = $value; + + return $this; + } + + /** + * Set the notification language. + * + * @return $this + */ + public function lang(string $value): static + { + $this->lang = $value; + + return $this; + } + + /** + * @return $this + */ + public function mutable(bool $value = true): static + { + $this->mutable = $value; + + return $this; + } + + /** + * Set the navigation target upon activation. + * + * @return $this + */ + public function navigate(string $value): static + { + $this->navigate = $value; + + return $this; + } + + /** + * @return $this + */ + public function renotify(bool $value = true): static + { + $this->renotify = $value; + + return $this; + } + + /** + * @return $this + */ + public function requireInteraction(bool $value = true): static + { + $this->requireInteraction = $value; + + return $this; + } + + /** + * @return $this + */ + public function silent(bool $value = true): static + { + $this->silent = $value; + + return $this; + } + + /** + * Set the notification tag. + * + * @return $this + */ + public function tag(string $value): static + { + $this->tag = $value; + + return $this; + } + + /** + * Set the timestamp associated with the notification. + * + * @return $this + */ + public function timestamp(int $value): static + { + $this->timestamp = $value; + + return $this; + } + + /** + * Set the notification vibration pattern. + * + * @param array $value + * @return $this + */ + public function vibrate(array $value): static + { + $this->vibrate = $value; + + return $this; + } + + /** + * Set the notification arbitrary data. + * + * @return $this + */ + public function data(mixed $value): static + { + $this->data = $value; + + return $this; + } + + /** + * Set the notification options. + * + * @link https://github.com/web-push-libs/web-push-php#notifications-and-default-options + * + * @param array $value + * @return $this + */ + public function options(array $value): static + { + $this->options = $value; + + return $this; + } + + /** + * Get the notification options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Get an array representation of the message. + * + * @return array + */ + public function toArray(): array + { + if (empty($this->title)) { + throw MessageValidationFailed::titleRequired(); + } + + if (empty($this->navigate)) { + throw MessageValidationFailed::navigateRequired(); + } + + return Arr::whereNotNull([ + 'web_push' => 8030, + 'notification' => Arr::except(array_filter(get_object_vars($this)), ['mutable', 'options']), + 'mutable' => $this->mutable ?? null, + ]); + } +} diff --git a/src/Events/NotificationFailed.php b/src/Events/NotificationFailed.php index 47e66c0..d7a1357 100644 --- a/src/Events/NotificationFailed.php +++ b/src/Events/NotificationFailed.php @@ -5,7 +5,7 @@ use Illuminate\Queue\SerializesModels; use Minishlink\WebPush\MessageSentReport; use NotificationChannels\WebPush\PushSubscription; -use NotificationChannels\WebPush\WebPushMessage; +use NotificationChannels\WebPush\WebPushMessageInterface; class NotificationFailed { @@ -16,7 +16,7 @@ class NotificationFailed * * @return void */ - public function __construct(public MessageSentReport $report, public PushSubscription $subscription, public WebPushMessage $message) + public function __construct(public MessageSentReport $report, public PushSubscription $subscription, public WebPushMessageInterface $message) { // } diff --git a/src/Events/NotificationSent.php b/src/Events/NotificationSent.php index 47d420c..f0783e0 100644 --- a/src/Events/NotificationSent.php +++ b/src/Events/NotificationSent.php @@ -5,7 +5,7 @@ use Illuminate\Queue\SerializesModels; use Minishlink\WebPush\MessageSentReport; use NotificationChannels\WebPush\PushSubscription; -use NotificationChannels\WebPush\WebPushMessage; +use NotificationChannels\WebPush\WebPushMessageInterface; class NotificationSent { @@ -16,7 +16,7 @@ class NotificationSent * * @return void */ - public function __construct(public MessageSentReport $report, public PushSubscription $subscription, public WebPushMessage $message) + public function __construct(public MessageSentReport $report, public PushSubscription $subscription, public WebPushMessageInterface $message) { // } diff --git a/src/Exceptions/MessageValidationFailed.php b/src/Exceptions/MessageValidationFailed.php new file mode 100644 index 0000000..f4168a2 --- /dev/null +++ b/src/Exceptions/MessageValidationFailed.php @@ -0,0 +1,16 @@ +isSuccess()) { $this->events->dispatch(new NotificationSent($report, $subscription, $message)); diff --git a/src/ReportHandlerInterface.php b/src/ReportHandlerInterface.php index afb9f72..68b6801 100644 --- a/src/ReportHandlerInterface.php +++ b/src/ReportHandlerInterface.php @@ -9,5 +9,5 @@ interface ReportHandlerInterface /** * Handle a message sent report. */ - public function handleReport(MessageSentReport $report, PushSubscription $subscription, WebPushMessage $message): void; + public function handleReport(MessageSentReport $report, PushSubscription $subscription, WebPushMessageInterface $message): void; } diff --git a/src/WebPushChannel.php b/src/WebPushChannel.php index b686098..ea01fab 100644 --- a/src/WebPushChannel.php +++ b/src/WebPushChannel.php @@ -31,7 +31,7 @@ public function send(mixed $notifiable, Notification $notification): void return; } - /** @var \NotificationChannels\WebPush\WebPushMessage $message */ + /** @var \NotificationChannels\WebPush\WebPushMessageInterface $message */ // @phpstan-ignore-next-line $message = $notification->toWebPush($notifiable, $notification); $payload = json_encode($message->toArray()); @@ -57,7 +57,7 @@ public function send(mixed $notifiable, Notification $notification): void * * @param \Illuminate\Database\Eloquent\Collection $subscriptions */ - protected function handleReports(Generator $reports, Collection $subscriptions, WebPushMessage $message): void + protected function handleReports(Generator $reports, Collection $subscriptions, WebPushMessageInterface $message): void { foreach ($reports as $report) { /** @var \Minishlink\WebPush\MessageSentReport $report */ diff --git a/src/WebPushMessage.php b/src/WebPushMessage.php index 22af1fa..ff3a349 100644 --- a/src/WebPushMessage.php +++ b/src/WebPushMessage.php @@ -2,7 +2,6 @@ namespace NotificationChannels\WebPush; -use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; /** @@ -10,7 +9,7 @@ * * @link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#Parameters */ -class WebPushMessage implements Arrayable +class WebPushMessage implements WebPushMessageInterface { protected string $title; diff --git a/src/WebPushMessageInterface.php b/src/WebPushMessageInterface.php new file mode 100644 index 0000000..570f778 --- /dev/null +++ b/src/WebPushMessageInterface.php @@ -0,0 +1,10 @@ +app->make(ReportHandler::class)); + $message = ($notification = new TestDeclarativeNotification)->toWebPush(null, null); + + $webpush->shouldReceive('queueNotification') + ->once() + ->withArgs(function (Subscription $subscription, string $payload, array $options, array $auth = []) use ($message): true { + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertEquals('endpoint', $subscription->getEndpoint()); + $this->assertEquals('key', $subscription->getPublicKey()); + $this->assertEquals('token', $subscription->getAuthToken()); + $this->assertEquals('aesgcm', $subscription->getContentEncoding()); + $this->assertSame($message->getOptions(), $options); + $this->assertSame(json_encode($message->toArray()), $payload); + + return true; + }) + ->andReturn(true); + + $webpush->shouldReceive('flush') + ->once() + ->andReturn((function () { + yield new MessageSentReport(new Request('POST', 'endpoint'), null, true); + })()); + + $this->testUser->updatePushSubscription('endpoint', 'key', 'token', 'aesgcm'); + + $channel->send($this->testUser, $notification); + + Event::assertDispatched(NotificationSent::class); + } + #[Test] public function subscriptions_with_invalid_endpoint_are_deleted(): void { diff --git a/tests/DeclarativeMessageTest.php b/tests/DeclarativeMessageTest.php new file mode 100644 index 0000000..8df8d84 --- /dev/null +++ b/tests/DeclarativeMessageTest.php @@ -0,0 +1,217 @@ +message = new DeclarativeWebPushMessage; + $this->message->title('Message title'); + $this->message->navigate('https://example.com'); + } + + #[Test] + public function title_can_be_set(): void + { + $this->message->title('New message title'); + + $this->assertEquals('New message title', $this->message->toArray()['notification']['title']); + } + + #[Test] + public function title_must_be_set(): void + { + $this->message = new DeclarativeWebPushMessage; + + $this->expectException(MessageValidationFailed::class); + + $this->expectExceptionMessage('title'); + + $this->message->toArray(); + } + + #[Test] + public function navigate_can_be_set(): void + { + $this->message->navigate('https://new-example.com'); + + $this->assertEquals('https://new-example.com', $this->message->toArray()['notification']['navigate']); + } + + #[Test] + public function navigate_must_be_set(): void + { + $this->message = new DeclarativeWebPushMessage; + + $this->message->title('Message title'); + + $this->expectException(MessageValidationFailed::class); + + $this->expectExceptionMessage('navigate'); + + $this->message->toArray(); + } + + #[Test] + public function action_can_be_set(): void + { + $this->message->action('Some Action', 'some_action', 'https://example.com/action'); + + $this->assertEquals( + [['title' => 'Some Action', 'action' => 'some_action', 'navigate' => 'https://example.com/action']], $this->message->toArray()['notification']['actions'] + ); + } + + #[Test] + public function action_can_be_set_with_icon(): void + { + $this->message->action('Some Action', 'some_action', 'https://example.com/action', '/icon.png'); + + $this->assertEquals( + [['title' => 'Some Action', 'action' => 'some_action', 'navigate' => 'https://example.com/action', 'icon' => '/icon.png']], $this->message->toArray()['notification']['actions'] + ); + } + + #[Test] + public function badge_can_be_set(): void + { + $this->message->badge('/badge.jpg'); + + $this->assertEquals('/badge.jpg', $this->message->toArray()['notification']['badge']); + } + + #[Test] + public function body_can_be_set(): void + { + $this->message->body('Message body'); + + $this->assertEquals('Message body', $this->message->toArray()['notification']['body']); + } + + #[Test] + public function direction_can_be_set(): void + { + $this->message->dir('rtl'); + + $this->assertEquals('rtl', $this->message->toArray()['notification']['dir']); + } + + #[Test] + public function icon_can_be_set(): void + { + $this->message->icon('/icon.jpg'); + + $this->assertEquals('/icon.jpg', $this->message->toArray()['notification']['icon']); + } + + #[Test] + public function image_can_be_set(): void + { + $this->message->image('/image.jpg'); + + $this->assertEquals('/image.jpg', $this->message->toArray()['notification']['image']); + } + + #[Test] + public function lang_can_be_set(): void + { + $this->message->lang('en'); + + $this->assertEquals('en', $this->message->toArray()['notification']['lang']); + } + + #[Test] + public function mutable_can_be_set(): void + { + $this->message->mutable(); + + $this->assertTrue($this->message->toArray()['mutable']); + } + + #[Test] + public function renotify_can_be_set(): void + { + $this->message->renotify(); + + $this->assertTrue($this->message->toArray()['notification']['renotify']); + } + + #[Test] + public function require_interaction_can_be_set(): void + { + $this->message->requireInteraction(); + + $this->assertTrue($this->message->toArray()['notification']['requireInteraction']); + } + + #[Test] + public function silent_can_be_set(): void + { + $this->message->silent(); + + $this->assertTrue($this->message->toArray()['notification']['silent']); + } + + #[Test] + public function tag_can_be_set(): void + { + $this->message->tag('tag1'); + + $this->assertEquals('tag1', $this->message->toArray()['notification']['tag']); + } + + #[Test] + public function timestamp_can_be_set(): void + { + $this->message->timestamp(1763059844); + + $this->assertEquals(1763059844, $this->message->toArray()['notification']['timestamp']); + } + + #[Test] + public function vibration_pattern_can_be_set(): void + { + $this->message->vibrate([1, 2, 3]); + + $this->assertEquals([1, 2, 3], $this->message->toArray()['notification']['vibrate']); + } + + #[Test] + public function arbitrary_data_can_be_set(): void + { + $this->message->data(['id' => 1]); + + $this->assertEquals(['id' => 1], $this->message->toArray()['notification']['data']); + } + + #[Test] + public function payload_is_declarative_message(): void + { + $this->assertEquals(8030, $this->message->toArray()['web_push']); + } + + #[Test] + public function options_can_be_set(): void + { + $this->message->options(['ttl' => 60]); + + $this->assertEquals(['ttl' => 60], $this->message->getOptions()); + $this->assertArrayNotHasKey('options', $this->message->toArray()); + } + + #[Test] + public function options_contain_json_content_type(): void + { + $this->assertEquals(['contentType' => 'application/json'], $this->message->getOptions()); + } +} diff --git a/tests/TestDeclarativeNotification.php b/tests/TestDeclarativeNotification.php new file mode 100644 index 0000000..2a82012 --- /dev/null +++ b/tests/TestDeclarativeNotification.php @@ -0,0 +1,24 @@ +data(['id' => 1]) + ->title('Title') + ->navigate('https://example.com') + ->icon('Icon') + ->body('Body') + ->action('Title', 'Action', 'https://example.com/action') + ->options(['ttl' => 60]); + } +}