Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,19 @@ php artisan expo:tickets:check

You may create schedules to execute these commands.

### Priority management
### Batch support

You can implement the following interface on your notification :
```
YieldStudio\LaravelExpoNotifier\Contracts\UrgentExpoNotificationInterface
You can send notification in the next batch :
```php
(new ExpoMessage())
->to([$notifiable->expoTokens->value])
->title('A beautiful title')
->body('This is a content')
->channelId('default')
->shouldBatch();
```

If method **isUrgent() return false**, your notification will be saved into the database and considered as **Pending**.
Don't forget to schedule the `expo:notifications:send` command.

## Unit tests

Expand Down
15 changes: 10 additions & 5 deletions config/expo-notifications.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
<?php

use YieldStudio\LaravelExpoNotifier\Storage\ExpoTokenStorageMysql;
use YieldStudio\LaravelExpoNotifier\Storage\ExpoTicketStorageMysql;
use YieldStudio\LaravelExpoNotifier\Storage\ExpoPendingNotificationStorageMysql;
use YieldStudio\LaravelExpoNotifier\Storage\ExpoTicketStorageMysql;
use YieldStudio\LaravelExpoNotifier\Storage\ExpoTokenStorageMysql;

return [
/*
* If set to true, when InvalidExpoToken event is triggered, the token is automatically deleted.
*/
'automatically_delete_token' => true,

'drivers' => [
'token' => ExpoTokenStorageMysql::class,
'ticket' => ExpoTicketStorageMysql::class,
Expand All @@ -17,6 +22,6 @@
],
'service' => [
'api_url' => 'https://exp.host/--/api/v2/push',
'host' => 'exp.host'
]
];
'host' => 'exp.host',
],
];
8 changes: 0 additions & 8 deletions src/Contracts/UrgentExpoNotificationInterface.php

This file was deleted.

12 changes: 10 additions & 2 deletions src/Dto/ExpoMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ final class ExpoMessage implements Jsonable, Arrayable
/** iOS only */
public bool $mutableContent = false;

public bool $shouldBatch = false;

public static function create(): ExpoMessage
{
return new ExpoMessage();
Expand Down Expand Up @@ -135,13 +137,20 @@ public function priority(string $priority): self
return $this;
}

public function mutableContent(bool $mutableContent): self
public function mutableContent(bool $mutableContent = true): self
{
$this->mutableContent = $mutableContent;

return $this;
}

public function shouldBatch(bool $shouldBatch = true): self
{
$this->shouldBatch = $shouldBatch;

return $this;
}

public function toArray(): array
{
return [
Expand All @@ -162,7 +171,6 @@ public function toArray(): array
public function toExpoData(): array
{
return array_filter($this->toArray(), fn ($item) => ! is_null($item));
;
}

public function toJson($options = JSON_THROW_ON_ERROR): bool|string
Expand Down
10 changes: 10 additions & 0 deletions src/Events/InvalidExpoToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace YieldStudio\LaravelExpoNotifier\Events;

final class InvalidExpoToken
{
public function __construct(public readonly string $token)
{
}
}
15 changes: 1 addition & 14 deletions src/ExpoNotificationsChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@
namespace YieldStudio\LaravelExpoNotifier;

use Illuminate\Notifications\Notification;
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface;
use YieldStudio\LaravelExpoNotifier\Contracts\UrgentExpoNotificationInterface;
use YieldStudio\LaravelExpoNotifier\Dto\ExpoMessage;
use YieldStudio\LaravelExpoNotifier\Exceptions\ExpoNotificationsException;

final class ExpoNotificationsChannel
{
public function __construct(
protected readonly ExpoNotificationsService $expoNotificationsService,
protected readonly ExpoPendingNotificationStorageInterface $expoNotification
) {
}

Expand All @@ -25,20 +22,10 @@ public function send($notifiable, Notification $notification): void
{
/** @var ExpoMessage $expoMessage */
$expoMessage = $notification->toExpoNotification($notifiable);

if (empty($expoMessage->to)) {
return;
}

if ($notification instanceof UrgentExpoNotificationInterface && $notification->isUrgent()) {
$response = $this->expoNotificationsService->notify($expoMessage);

$this->expoNotificationsService->storeTicketsFromResponse(
collect($expoMessage->to),
$response
);
} else {
$this->expoNotification->store($expoMessage);
}
$this->expoNotificationsService->notify($expoMessage);
}
}
103 changes: 52 additions & 51 deletions src/ExpoNotificationsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,26 @@
namespace YieldStudio\LaravelExpoNotifier;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface;
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTicketStorageInterface;
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTokenStorageInterface;
use YieldStudio\LaravelExpoNotifier\Dto\ExpoMessage;
use YieldStudio\LaravelExpoNotifier\Dto\ExpoTicket;
use YieldStudio\LaravelExpoNotifier\Dto\PushReceiptResponse;
use YieldStudio\LaravelExpoNotifier\Dto\PushTicketResponse;
use YieldStudio\LaravelExpoNotifier\Enums\ExpoResponseStatus;
use YieldStudio\LaravelExpoNotifier\Events\InvalidExpoToken;
use YieldStudio\LaravelExpoNotifier\Exceptions\ExpoNotificationsException;

final class ExpoNotificationsService
{
private PendingRequest $http;

public function __construct(
string $apiUrl,
string $host,
protected readonly ExpoTokenStorageInterface $tokenStorage,
string $apiUrl,
string $host,
protected readonly ExpoPendingNotificationStorageInterface $notificationStorage,
protected readonly ExpoTicketStorageInterface $ticketStorage
) {
$this->http = Http::withHeaders([
Expand All @@ -35,17 +36,33 @@ public function __construct(
}

/**
* @param ExpoMessage|ExpoMessage[] $expoMessages
* @param ExpoMessage|ExpoMessage[]|Collection<int, ExpoMessage> $expoMessages
* @return Collection<int, PushTicketResponse>
* @throws ExpoNotificationsException
*/
public function notify(ExpoMessage|array $expoMessages): Collection
public function notify(ExpoMessage|Collection|array $expoMessages): Collection
{
if ($expoMessages instanceof ExpoMessage) {
$expoMessages = [$expoMessages];
/** @var Collection<int, ExpoMessage> $expoMessages */
$expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages));

$shouldBatchFilter = fn (ExpoMessage $message) => $message->shouldBatch;

// Store notifications to send in the next batch
$expoMessages
->filter($shouldBatchFilter)
->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message));

// Filter notifications to send now
$toSend = $expoMessages
->reject($shouldBatchFilter)
->map(fn (ExpoMessage $message) => $message->toExpoData())
->values();

if ($toSend->isEmpty()) {
return collect();
}

$response = $this->http->post('/send', array_map(fn ($item) => $item->toExpoData(), $expoMessages));
$response = $this->http->post('/send', $toSend->toArray());
if (! $response->successful()) {
throw new ExpoNotificationsException($response->toPsrResponse());
}
Expand All @@ -55,29 +72,35 @@ public function notify(ExpoMessage|array $expoMessages): Collection
throw new ExpoNotificationsException($response->toPsrResponse());
}

return collect($data['data'])->map(function ($responseItem) {
$tickets = collect($data['data'])->map(function ($responseItem) {
if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) {
$data = (new PushTicketResponse())
return (new PushTicketResponse())
->status($responseItem['status'])
->message($responseItem['message'])
->details($responseItem['details']);
} else {
$data = (new PushTicketResponse())
->status($responseItem['status'])
->ticketId($responseItem['id']);
}

return $data;
return (new PushTicketResponse())
->status($responseItem['status'])
->ticketId($responseItem['id']);
});

$this->checkAndStoreTickets($toSend->pluck('to')->flatten(), $tickets);

return $tickets;
}

/**
* @param array $tokenIds
* @param Collection<int, string>|array $tokenIds
* @return Collection<int, PushReceiptResponse>
* @throws ExpoNotificationsException
*/
public function receipts(array $tokenIds): Collection
public function receipts(Collection|array $tokenIds): Collection
{
if ($tokenIds instanceof Collection) {
$tokenIds = $tokenIds->toArray();
}

$response = $this->http->post('/getReceipts', ['ids' => $tokenIds]);
if (! $response->successful()) {
throw new ExpoNotificationsException($response->toPsrResponse());
Expand Down Expand Up @@ -108,44 +131,22 @@ public function receipts(array $tokenIds): Collection
* @param Collection<int, PushTicketResponse> $tickets
* @return void
*/
public function storeTicketsFromResponse(Collection $tokens, Collection $tickets): void
private function checkAndStoreTickets(Collection $tokens, Collection $tickets): void
{
$tokensToDelete = [];

$tickets
->intersectByKeys($tokens)
->each(function (PushTicketResponse $ticket, $index) use ($tokens, &$tokensToDelete) {
if ($ticket->status === ExpoResponseStatus::ERROR->value && $ticket->details['error'] === ExpoResponseStatus::DEVICE_NOT_REGISTERED->value) {
$tokensToDelete[] = $tokens->get($index);
->each(function (PushTicketResponse $ticket, $index) use ($tokens) {
if ($ticket->status === ExpoResponseStatus::ERROR->value) {
if (
is_array($ticket->details) &&
array_key_exists('error', $ticket->details) &&
$ticket->details['error'] === ExpoResponseStatus::DEVICE_NOT_REGISTERED->value
) {
event(new InvalidExpoToken($tokens->get($index)));
}
} else {
$this->ticketStorage->store($ticket->ticketId, $tokens->get($index));
}
});

$this->tokenStorage->delete($tokensToDelete);
}

/**
* @param Collection<int, ExpoTicket> $tickets
* @param Collection<int, PushReceiptResponse> $receipts
* @return void
*/
public function checkNotifyResponse(Collection $tickets, Collection $receipts): void
{
$tokensToDelete = [];
$ticketsToDelete = [];

$tickets->map(function (ExpoTicket $ticket) use ($receipts, &$tokensToDelete, &$ticketsToDelete) {
$receipt = $receipts->get($ticket->id);
if (in_array($receipt->status, [ExpoResponseStatus::OK->value, ExpoResponseStatus::ERROR->value])) {
if ($receipt->status === ExpoResponseStatus::ERROR->value) {
$tokensToDelete[] = $ticket->token;
}
$ticketsToDelete[] = $ticket->id;
}
});

$this->tokenStorage->delete($tokensToDelete);
$this->ticketStorage->delete($ticketsToDelete);
}
}
39 changes: 24 additions & 15 deletions src/ExpoNotificationsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,20 @@

namespace YieldStudio\LaravelExpoNotifier;

use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use YieldStudio\LaravelExpoNotifier\Commands\CheckTickets;
use YieldStudio\LaravelExpoNotifier\Commands\SendPendingNotifications;
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface;
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTicketStorageInterface;
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTokenStorageInterface;
use YieldStudio\LaravelExpoNotifier\Events\InvalidExpoToken;
use YieldStudio\LaravelExpoNotifier\Listeners\DeleteInvalidExpoToken;

final class ExpoNotificationsServiceProvider extends ServiceProvider
{
public function boot(): void
public function register(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');

$this->publishes([
__DIR__.'/../config' => config_path(),
], 'expo-notifications-config');

$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
], 'expo-notifications-migration');

$this->app->bind(ExpoTokenStorageInterface::class, config('expo-notifications.drivers.token'));
$this->app->bind(ExpoTicketStorageInterface::class, config('expo-notifications.drivers.ticket'));
$this->app->bind(ExpoPendingNotificationStorageInterface::class, config('expo-notifications.drivers.notification'));
Expand All @@ -36,18 +29,34 @@ public function boot(): void
return new ExpoNotificationsService(
$apiUrl,
$host,
$app->make(ExpoTokenStorageInterface::class),
$app->make(ExpoPendingNotificationStorageInterface::class),
$app->make(ExpoTicketStorageInterface::class)
);
});
}

public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');

$this->publishes([
__DIR__.'/../config' => config_path(),
], 'expo-notifications-config');

$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
], 'expo-notifications-migration');

$this->commands([
SendPendingNotifications::class,
CheckTickets::class,
]);
}

public function register(): void
{
if (config('expo-notifications.automatically_delete_token', false)) {
Event::listen(
InvalidExpoToken::class,
[DeleteInvalidExpoToken::class, 'handle']
);
}
}
}
Loading