diff --git a/.env.example b/.env.example index 71a4d6b48..ae94f08c7 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,7 @@ VITE_APP_NAME="${APP_NAME}" REMOTES_REQUESTS=false REMOTES_EVERYONE=false + +WEB_PUSH_SUBJECT=https://dev-null.sh +WEB_PUSH_PUBLIC_KEY= +WEB_PUSH_PRIVATE_KEY= diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 34ff6978d..989fd7b7b 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -330,9 +330,15 @@ Pending replies, quotes, and mentions wait for approval before notifying others. Sources: [notifications page](resources/js/pages/notification/index.tsx), [notification filtering](app/Queries/NotificationBuilder.php), [grouping](app/Queries/NotificationGroupsQuery.php), [moderation notifications](app/Actions/UpdateCommitStatuses.php). -Remotes can also show **browser notifications** and play a **notification sound**, controlled independently under **Remotes** in your account's **Settings → Profile**. Both are on by default. Choose **Allow notifications** there if your browser still needs permission for banners; the site never requests permission automatically on page load. Sound does not require notification permission and can stay on when banners are off. Each choice stays saved for the signed-in account on that browser, and an earlier opt-out from combined alerts keeps both off until you change them. Alerts work only while the site is open, including background tabs where the browser allows polling; they are not push notifications after closing the site. Browsers may suspend background tabs or block sound until you interact with the page. Alerts never show message text. Only the sound has a 10-second cooldown shared across remotes and tabs; notification banners continue updating during a burst. The browser tab title shows the combined unread notification and remote message counts from the navigation, such as **(2)**, caps the display at **(99+)** above 99, and removes the prefix when nothing remains unread. The count does not depend on sound or notification permission. Alerts skip only the remote you are actively reading in a visible, focused tab, whether on the Remotes page or in the dock. Messages in other remotes can alert while you browse any page, including a different remote or the Remotes inbox. Switching away lets new messages alert again, without replaying messages suppressed while you were reading that remote. Existing unread messages do not trigger alerts when you open the site. +Remotes alerts are controlled under **Settings → Profile → Remotes**. **On-site notifications** and **Notification sound** are on by default. On-site alerts stay inside /dev/null and never ask for browser permission. Sound does not require notification permission and plays only while the site is running; the browser may require interaction before it can play. -With **Notifications** enabled, a new remote alert alternates the browser tab title between its normal title and **Alice in dev/null core sent you a message**, or **Alice sent you a message** for a direct remote. It switches every second for three normal/message cycles, then keeps the message title until you view the receiving remote. All already-open tabs for your account share the alert through the browser's native BroadcastChannel API; opening that remote in a visible, focused tab restores its alert titles to the normal titles and unread counts everywhere. Focusing an unrelated page or a different remote does not dismiss it. A newer alert updates the sender without restarting the blinking, and the sound cooldown does not delay it. This uses the browser's native page title and works even without permission for notification banners. Message text is never included, and muted or actively read remotes do not trigger it. +**Off-site notifications** are off by default. Turn that separate switch on to allow system notifications from this browser or installed PWA, including when closed. This is an explicit choice for each account and browser; an existing browser permission or an earlier on-site preference does not turn it on. On iPhone and iPad, add /dev/null to your Home Screen and turn the switch on from the installed app. The browser asks for permission only after you choose to enable off-site notifications. Declining permission or a rejected enrollment leaves them off. If /dev/null cannot confirm that a failed enrollment was removed, the switch stays on with an error so you can try turning it off again. Turning it off revokes that account's delivery registration; the browser permission and shared browser subscription can remain for reuse. On-site alerts remain independent. Up to 20 browsers or installed apps per account can have off-site notifications enabled. At the limit, existing enrollments stay active; turn off off-site notifications in one before enabling another. + +Off-site alerts are silent, contain no message data, and open the Remotes inbox. Delivery depends on browser and operating-system permissions, background delivery, and Focus settings. A fully quit browser may prevent delivery. Delivery registrations are removed on sign-out, account deletion, turning Remotes off, or when the push service reports that they expired. Delivery briefly waits and rechecks unread messages, membership, mutes, and blocks before sending. + +Choices stay saved for the signed-in account on that browser. Earlier combined alert opt-outs continue to apply to on-site alerts and sound until each is changed. Alerts never show message text. Only sound has a 10-second cooldown shared across remotes and tabs. The browser tab title shows the combined unread notification and remote message counts from navigation, such as **(2)**, caps the display at **(99+)** above 99, and removes the prefix when nothing remains unread. Counts do not depend on sound or off-site permission. While the site is open, alerts skip the remote you are actively reading in a visible, focused tab, whether on the Remotes page or in the dock. Messages in other remotes can alert while you browse any page. Existing unread messages do not trigger on-site alerts when you open the site. + +With **On-site notifications** enabled, a new remote alert alternates the browser tab title between its normal title and **Alice in dev/null core sent you a message**, or **Alice sent you a message** for a direct remote. It switches every second for three normal/message cycles, then keeps the message title until you view the receiving remote. All already-open tabs for your account share the alert through the browser's native BroadcastChannel API; opening that remote in a visible, focused tab restores its alert titles to the normal titles and unread counts everywhere. Focusing an unrelated page or a different remote does not dismiss it. A newer alert updates the sender without restarting the blinking, and the sound cooldown does not delay it. This uses the browser's native page title and works even without permission for notification banners. Message text is never included, and muted or actively read remotes do not trigger it. Pin a direct remote or org from its **…** menu to keep it above unpinned conversations in your inbox. Pinned conversations stay ordered by their latest message. Pins belong to your account and follow you across devices; other participants do not see them. Choose **Unpin** to return a remote to its usual place. Stashing keeps its pin, and it returns pinned when a new message pops it back into Open. @@ -408,7 +414,7 @@ Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane /dev/null is a progressive web app. Browsers that support installation (Chrome, Edge, and Safari's **Add to Home Screen** / **Add to Dock**) offer to install it from the web app manifest; the installed app opens in its own window without browser controls, uses the app icon, and follows the Light, Dark, or System appearance in its title and status bars. The app's icon menu offers shortcuts to the Following log, the Latest log, and Notifications. Where the browser supports it, links launched into the installed app reuse its existing window; installing the app does not guarantee that every browser will open links in it. -A service worker caches the built assets of the current release and shows an offline page when a page cannot be reached; the page reloads on its own once the connection returns. The service worker does not cache pages or retain signed-in page content for offline access, and there is no offline reading of logs or offline posting. Browser-local preferences and [stashed drafts](#stashed-drafts) are separate from this asset cache. There are no push notifications; the installed app polls for new notifications and messages the same way the browser does. +A service worker caches the built assets of the current release and shows an offline page when a page cannot be reached; the page reloads on its own once the connection returns. The service worker does not cache pages or retain signed-in page content for offline access, and there is no offline reading of logs or offline posting. Browser-local preferences and [stashed drafts](#stashed-drafts) are separate from this asset cache. While open, the installed app polls for new notifications and messages the same way the browser does. Remotes can also send silent, generic push notifications while the app is backgrounded or closed when you explicitly enable **Off-site notifications** under **Settings → Profile → Remotes**. This switch is off by default; activity notifications remain on-site. Sources: [web app manifest](public/site.webmanifest), [service worker](public/sw.js), [offline page](public/offline.html), [service worker registration](resources/js/lib/service-worker.ts), [document head](resources/views/app.blade.php), [manifest tests](tests/Feature/WebAppManifestTest.php). diff --git a/PRODUCT.md b/PRODUCT.md index 799c72b9f..be9461735 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -36,7 +36,7 @@ What exists, one line each (details in the knowledge base): - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). - **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. - **Remotes:** private one-to-one and group messages (orgs). -- **Notifications:** in-app activity, plus independently controlled browser and sound alerts for Remotes while the site is open, enabled by default; only browser banners require notification permission. +- **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open. - **Search:** developers by name, commits by meaning. - **Emails:** an occasional update the admins write to everyone, or to a filtered audience, with one-click unsubscribe. Never per-activity notifications. - **Safety:** mute, block, reports, and moderation. @@ -51,7 +51,7 @@ Rules every feature must keep: - **Store as little as possible.** Assume the database has leaked: no data without a feature that needs it, sensitive content encrypted, no secrets stored, and everything about a person erased with their account and included in their export (see `.ai/guidelines/data.blade.php`). The one deliberate exception is moderation: bans and timeouts are kept by GitHub account after deletion, holding only the GitHub id, reason, and dates, so deleting an account and signing up again can't escape or reset them. - **Same rules on every surface.** Web, mobile API, terminal, and Action share validation, moderation, visibility, and rate limits. -Deliberately excluded: sign-up or identity outside GitHub, editing commits, and email or push notifications. +Deliberately excluded: sign-up or identity outside GitHub, editing commits, and email activity notifications. ## Brand Commitments diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 269e48405..b4ad5b244 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -12,6 +12,7 @@ use App\Models\Message; use App\Models\Poll; use App\Models\PollOption; +use App\Models\PushSubscription; use App\Models\User; use App\Models\UserTimeout; use App\Queries\CommitBuilder; @@ -48,6 +49,7 @@ * remote_mutes: array, * notifications: Generator>, * ssh_keys: array, + * push_subscriptions: array, * devices: array, * timeouts: array, * sessions: Collection @@ -139,6 +141,9 @@ public function handle(User $user): array ->all(), 'notifications' => $this->notifications($user), 'ssh_keys' => $user->sshKeys()->pluck('fingerprint')->all(), + 'push_subscriptions' => $user->pushSubscriptions()->get()->map(fn (PushSubscription $subscription): array => [ + 'id' => $subscription->id, + ])->all(), 'devices' => $user->devices()->oldest('approved_at')->get()->map(fn (Device $device): array => [ 'name' => $device->name, 'fingerprint' => $device->fingerprint, diff --git a/app/Actions/CreateMessage.php b/app/Actions/CreateMessage.php index 8eed2413b..d45b63fa9 100644 --- a/app/Actions/CreateMessage.php +++ b/app/Actions/CreateMessage.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Enums\RemoteStatus; +use App\Jobs\SendRemotePush; use App\Jobs\UnfurlLink; use App\Models\Conversation; use App\Models\ConversationParticipant; @@ -89,6 +90,13 @@ public function handle(User $sender, Conversation $conversation, string $body, s self::advance($conversation, $message, unstash: true); + if (config('services.web_push.public_key') && config('services.web_push.private_key')) { + foreach (ConversationParticipant::query()->where('conversation_id', $conversation->id) + ->where('user_id', '!=', $sender->id)->whereHas('user.pushSubscriptions')->get(['user_id']) as $participant) { + dispatch(new SendRemotePush($message->id, $participant->user_id))->delay(now()->addSeconds(10))->afterCommit(); + } + } + ConversationParticipant::query() ->where('conversation_id', $conversation->id) ->where('user_id', $sender->id) diff --git a/app/Actions/CreatePushSubscription.php b/app/Actions/CreatePushSubscription.php new file mode 100644 index 000000000..65e006859 --- /dev/null +++ b/app/Actions/CreatePushSubscription.php @@ -0,0 +1,20 @@ +updateOrCreate( + ['endpoint_hash' => hash('sha256', $endpoint)], + ['user_id' => $user->id, 'endpoint' => $endpoint], + ); + } +} diff --git a/app/Actions/DeletePushSubscription.php b/app/Actions/DeletePushSubscription.php new file mode 100644 index 000000000..1e18e1795 --- /dev/null +++ b/app/Actions/DeletePushSubscription.php @@ -0,0 +1,16 @@ +where('user_id', $user->id)->where('endpoint_hash', $endpointHash)->delete(); + } +} diff --git a/app/Actions/UpdateUserRemotes.php b/app/Actions/UpdateUserRemotes.php index 34666dbe5..1920940b9 100644 --- a/app/Actions/UpdateUserRemotes.php +++ b/app/Actions/UpdateUserRemotes.php @@ -11,5 +11,9 @@ public function handle(User $user, bool $enabled): void { $user->update(['remotes_enabled' => $enabled]); + + if (! $enabled) { + $user->pushSubscriptions()->delete(); + } } } diff --git a/app/Http/Controllers/PushSubscriptionController.php b/app/Http/Controllers/PushSubscriptionController.php new file mode 100644 index 000000000..416893b16 --- /dev/null +++ b/app/Http/Controllers/PushSubscriptionController.php @@ -0,0 +1,40 @@ +json([ + 'public_key' => config('services.web_push.private_key') ? config('services.web_push.public_key') : null, + ])->header('Cache-Control', 'private, no-store'); + } + + public function store(CreatePushSubscriptionRequest $request, #[CurrentUser] User $user, CreatePushSubscription $action): Response + { + $subscription = $action->handle($user, $request->string('endpoint')->toString()); + $request->session()->put('push_endpoint_hash', $subscription->endpoint_hash); + + return response()->noContent(); + } + + public function destroy(DeletePushSubscriptionRequest $request, #[CurrentUser] User $user, DeletePushSubscription $action): Response + { + $action->handle($user, hash('sha256', $request->string('endpoint')->toString())); + $request->session()->forget('push_endpoint_hash'); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index e2f63697b..8725d47a1 100644 --- a/app/Http/Controllers/SessionController.php +++ b/app/Http/Controllers/SessionController.php @@ -5,9 +5,11 @@ namespace App\Http\Controllers; use App\Actions\CreateTomFriendship; +use App\Actions\DeletePushSubscription; use App\Actions\UpdateOrCreateUser; use App\Jobs\ImportGitHubFollowsJob; use App\Jobs\SyncGitHubSshKeysJob; +use App\Models\User; use App\Services\GitHubAccountScreening; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -68,8 +70,13 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri return redirect()->intended(route('log.show', $user->log, absolute: false)); } - public function destroy(Request $request): RedirectResponse + public function destroy(Request $request, DeletePushSubscription $deletePushSubscription): RedirectResponse { + $user = $request->user(); + assert($user instanceof User); + $endpointHash = $request->session()->get('push_endpoint_hash'); + $deletePushSubscription->handle($user, is_string($endpointHash) ? $endpointHash : ''); + Auth::guard('web')->logout(); $request->session()->invalidate(); diff --git a/app/Http/Middleware/SerializePushSessions.php b/app/Http/Middleware/SerializePushSessions.php new file mode 100644 index 000000000..617a6a618 --- /dev/null +++ b/app/Http/Middleware/SerializePushSessions.php @@ -0,0 +1,33 @@ +cookie(Config::string('session.cookie')); + + if (! is_string($sessionId)) { + return $next($request); + } + + // Run before StartSession and hold through its save; a cache lease can expire mid-enrollment. + return DB::transaction(function () use ($sessionId, $request, $next): Response { + DB::select('select pg_advisory_xact_lock(hashtextextended(?, 0))', [hash('sha256', 'remote-push-session:'.$sessionId)]); + + return $next($request); + }); + } +} diff --git a/app/Http/Middleware/SerializePushSubscriptions.php b/app/Http/Middleware/SerializePushSubscriptions.php new file mode 100644 index 000000000..974fb140f --- /dev/null +++ b/app/Http/Middleware/SerializePushSubscriptions.php @@ -0,0 +1,31 @@ +user(); + assert($user instanceof User); + + // Hold the account lock across validation and creation, including concurrent browser enrollments. + return DB::transaction(function () use ($user, $request, $next): Response { + User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); + $user->refresh(); + + return $next($request); + }); + } +} diff --git a/app/Http/Requests/CreatePushSubscriptionRequest.php b/app/Http/Requests/CreatePushSubscriptionRequest.php new file mode 100644 index 000000000..30834cdbb --- /dev/null +++ b/app/Http/Requests/CreatePushSubscriptionRequest.php @@ -0,0 +1,56 @@ +user(); + assert($user instanceof User); + + if ($this->input('user_id') !== $user->id) { + return Response::deny('Your signed-in account changed.'); + } + + return $user->remotes_enabled && config('services.web_push.public_key') && config('services.web_push.private_key') + ? Response::allow() : Response::deny(); + } + + /** + * @return array> + */ + public function rules(): array + { + return ['endpoint' => ['required', 'string', 'max:2048', new PushEndpoint]]; + } + + /** + * @return list + */ + public function after(): array + { + return [function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $user = $this->user(); + assert($user instanceof User); + + if (! $user->pushSubscriptions()->where('endpoint_hash', hash('sha256', $this->string('endpoint')->toString()))->exists() + && $user->pushSubscriptions()->count() >= PushSubscription::MAX_PER_USER) { + $validator->errors()->add('endpoint', 'You have off-site notifications enabled in '.PushSubscription::MAX_PER_USER.' browsers or apps. Turn them off in one before enabling another.'); + } + }]; + } +} diff --git a/app/Http/Requests/DeletePushSubscriptionRequest.php b/app/Http/Requests/DeletePushSubscriptionRequest.php new file mode 100644 index 000000000..3b381d325 --- /dev/null +++ b/app/Http/Requests/DeletePushSubscriptionRequest.php @@ -0,0 +1,28 @@ +user(); + assert($user instanceof User); + + return $this->input('user_id') === $user->id ? Response::allow() : Response::deny('Your signed-in account changed.'); + } + + /** + * @return array> + */ + public function rules(): array + { + return ['endpoint' => ['required', 'string', 'max:2048']]; + } +} diff --git a/app/Jobs/SendRemotePush.php b/app/Jobs/SendRemotePush.php new file mode 100644 index 000000000..b30b6c9d4 --- /dev/null +++ b/app/Jobs/SendRemotePush.php @@ -0,0 +1,80 @@ +|null $subscriptionIds + */ + public function __construct( + public readonly int $messageId, + public readonly string $userId, + public readonly ?array $subscriptionIds = null, + public readonly int $tries = 3, + ) { + // + } + + public function handle(RemotePush $push): void + { + $message = Message::query()->find($this->messageId); + + if (! $message instanceof Message || $message->event !== null || $message->created_at->lt(Date::now()->subMinutes(5)) + || ! config('services.web_push.public_key') || ! config('services.web_push.private_key')) { + return; + } + + $participant = ConversationParticipant::query()->where('conversation_id', $message->conversation_id) + ->where('user_id', $this->userId) + ->where('user_id', '!=', $message->user_id) + ->where('visible_from_id', '<', $message->id) + ->where('read_through_id', '<', $message->id) + ->whereIn('status', config('remotes.requests') ? [RemoteStatus::Open, RemoteStatus::Fetched] : [RemoteStatus::Open]) + ->where(fn (Builder $query): Builder => $query->whereNull('muted_until_at')->orWhere('muted_until_at', '<=', now())) + ->whereHas('user.pushSubscriptions') + ->with('user')->first(); + + if (! $participant instanceof ConversationParticipant) { + return; + } + + $user = $participant->user; + + if (! $user->remotes_enabled || $user->hasBlockWith($message->user) || $user->isMuting($message->user)) { + return; + } + + $failed = []; + + foreach (PushSubscription::query()->where('user_id', $user->id) + ->when($this->subscriptionIds !== null, fn (Builder $query): Builder => $query->whereKey($this->subscriptionIds)) + ->orderBy('id')->limit(PushSubscription::MAX_PER_USER)->get() as $subscription) { + if (! $push->send($subscription)) { + $failed[] = $subscription->id; + } + } + + if ($failed !== [] && $this->attempts() < $this->tries) { + // A follow-up starts with a fresh queue attempt count, so carry the remaining budget. + dispatch(new self($this->messageId, $this->userId, $failed, $this->tries - $this->attempts()) + ->onConnection($this->connection)->onQueue($this->queue))->delay(now()->addSeconds(30)); + } + } +} diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php new file mode 100644 index 000000000..23f7d00dd --- /dev/null +++ b/app/Models/PushSubscription.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + use HasUuids; + + public const int MAX_PER_USER = 20; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'user_id' => 'string', + 'endpoint_hash' => 'string', + 'endpoint' => 'encrypted', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 4360ca6ed..db64ec385 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -92,6 +92,7 @@ 'lane_color', 'pasteis_mode', 'remotes_enabled', + 'pushSubscriptions', 'remotes_from', 'pull_receipts', 'pasteis_received', @@ -242,6 +243,14 @@ public function sshKeys(): HasMany return $this->hasMany(SshKey::class); } + /** + * @return HasMany + */ + public function pushSubscriptions(): HasMany + { + return $this->hasMany(PushSubscription::class); + } + /** * @return HasMany */ diff --git a/app/Rules/PushEndpoint.php b/app/Rules/PushEndpoint.php new file mode 100644 index 000000000..27e5977d8 --- /dev/null +++ b/app/Rules/PushEndpoint.php @@ -0,0 +1,31 @@ + Config::string('services.web_push.subject'), + 'publicKey' => Config::string('services.web_push.public_key'), + 'privateKey' => Config::string('services.web_push.private_key'), + ]); + $endpoint = $subscription->endpoint; + $headers = VAPID::getVapidHeaders( + 'https://'.parse_url($endpoint, PHP_URL_HOST), + $auth['subject'], $auth['publicKey'], $auth['privateKey'], ContentEncoding::aes128gcm, + ); + + $windows = str_ends_with((string) parse_url($endpoint, PHP_URL_HOST), '.notify.windows.com'); + + // An empty authenticated push needs no browser encryption secret or message data. + try { + $response = Http::withHeaders([...$headers, 'TTL' => '300', 'Topic' => 'devnull-remotes', ...$windows ? ['X-WNS-Type' => 'wns/raw'] : ['Urgency' => 'normal']]) + ->withOptions(['allow_redirects' => false])->timeout(10)->withBody('', 'application/octet-stream')->post($endpoint); + } catch (ConnectionException) { + return false; + } + + if (in_array($response->status(), [404, 410], true)) { + $subscription->delete(); + } + + return ! $response->serverError() && $response->status() !== 429; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 7d89b2908..bfc82a8a5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,6 +11,7 @@ use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\PreventFraming; use App\Http\Middleware\RedirectBannedUsers; +use App\Http\Middleware\SerializePushSessions; use App\Http\Middleware\StripNullBytes; use App\Http\Middleware\UpdateLastSeenAt; use App\Models\User; @@ -26,6 +27,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Middleware\ThrottleRequests; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; @@ -49,6 +51,7 @@ // Rate limits apply to the user authenticated by their SSH signature... $middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSshSignature::class); $middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSignedRequest::class); + $middleware->prependToPriorityList(before: StartSession::class, prepend: SerializePushSessions::class); $middleware->web(append: [ HandleAppearance::class, diff --git a/composer.json b/composer.json index 7398bb672..13b6d5152 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/socialite": "^5.31.0", "laravel/wayfinder": "^0.1.23", "league/flysystem-aws-s3-v3": "^3.35.3", + "minishlink/web-push": "^11.0", "nunomaduro/essentials": "^1.2.0", "phpseclib/phpseclib": "^4.0.2", "resend/resend-php": "^1.17", diff --git a/composer.lock b/composer.lock index df440131b..80c8c1c69 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f6112468a2fda879fb903b4c4f7c1c3d", + "content-hash": "169e47b41d0e981a7238dc3cb9e0b900", "packages": [ { "name": "aws/aws-crt-php", @@ -2761,6 +2761,85 @@ ], "time": "2026-10-16T15:19:33+00:00" }, + { + "name": "minishlink/web-push", + "version": "v11.0.0", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f8410afb73486ab895bb4792b879bdca62025b47", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "php": ">=8.2", + "php-http/discovery": "^1.19", + "php-http/httplug": "^2.4", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "psr/log": "^2.0|^3.0", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.92.2", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^2.7", + "php-http/guzzle7-adapter": "^1.1", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance.", + "php-http/guzzle7-adapter": "Enables concurrent sending via WebPush::flushPooled() if you use Guzzle." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v11.0.0" + }, + "time": "2026-07-23T16:10:05+00:00" + }, { "name": "monolog/monolog", "version": "3.12.1", @@ -3430,6 +3509,194 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", @@ -4297,6 +4564,181 @@ }, "time": "2026-10-20T03:25:21+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.6.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/792e909d4e387adffe3c4f404451c7d57a3d2022", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering", + "ext-sodium": "To verify Ed25519 signatures where the OpenSSL extension has no EdDSA" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-12T19:02:49+00:00" + }, { "name": "symfony/clock", "version": "v8.1.9", @@ -5943,6 +6385,86 @@ ], "time": "2026-09-25T15:50:05+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.43.0", @@ -7358,6 +7880,96 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.2.3", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "db962c3ad8bc91c531a49dd373f0a641fc507bfd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/db962c3ad8bc91c531a49dd373f0a641fc507bfd", + "reference": "db962c3ad8bc91c531a49dd373f0a641fc507bfd", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1", + "symfony/deprecation-contracts": "^2.5|^3.0" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.2.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-12T18:50:35+00:00" } ], "packages-dev": [ diff --git a/config/services.php b/config/services.php index c834f80bc..5aa7c8951 100644 --- a/config/services.php +++ b/config/services.php @@ -4,6 +4,12 @@ return [ + 'web_push' => [ + 'subject' => env('WEB_PUSH_SUBJECT', 'https://dev-null.sh'), + 'public_key' => env('WEB_PUSH_PUBLIC_KEY'), + 'private_key' => env('WEB_PUSH_PRIVATE_KEY'), + ], + /* |-------------------------------------------------------------------------- | Third Party Services diff --git a/database/factories/PushSubscriptionFactory.php b/database/factories/PushSubscriptionFactory.php new file mode 100644 index 000000000..5593f9648 --- /dev/null +++ b/database/factories/PushSubscriptionFactory.php @@ -0,0 +1,29 @@ + + */ +final class PushSubscriptionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $endpoint = 'https://fcm.googleapis.com/wp/'.fake()->uuid(); + + return [ + 'user_id' => User::factory(), + 'endpoint' => $endpoint, + 'endpoint_hash' => hash('sha256', $endpoint), + ]; + } +} diff --git a/database/migrations/2026_10_25_120000_create_push_subscriptions_table.php b/database/migrations/2026_10_25_120000_create_push_subscriptions_table.php new file mode 100644 index 000000000..e8cf56934 --- /dev/null +++ b/database/migrations/2026_10_25_120000_create_push_subscriptions_table.php @@ -0,0 +1,21 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->char('endpoint_hash', 64)->unique(); + $table->text('endpoint'); + $table->index('user_id'); + }); + } +}; diff --git a/package.json b/package.json index 29860b0e9..0fed34885 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts" + "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts" }, "dependencies": { "@daypicker/react": "^10.0.2", diff --git a/phpunit.xml b/phpunit.xml index 9ca6d4aab..d59b95bbe 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -37,6 +37,8 @@ + + diff --git a/public/sw.js b/public/sw.js index 9f4536fc3..8c26c3f17 100644 --- a/public/sw.js +++ b/public/sw.js @@ -19,6 +19,16 @@ const assetCache = `assets-${version}`; const offlineUrl = '/offline.html'; const offlineFiles = [offlineUrl, '/favicon.ico', '/favicon.svg']; +self.addEventListener('push', (event) => { + event.waitUntil(self.registration.showNotification('/dev/null · Remotes', { + body: 'You have a new message in Remotes.', + icon: '/icon-192.png', + tag: 'devnull-remotes', + silent: true, + data: { url: '/remotes' }, + })); +}); + self.addEventListener('notificationclick', (event) => { event.notification.close(); const url = new URL(event.notification.data?.url ?? '/', self.location.origin); @@ -26,7 +36,7 @@ self.addEventListener('notificationclick', (event) => { if (url.origin !== self.location.origin) return; event.waitUntil(self.clients.matchAll({ type: 'window' }).then(async (clients) => { - const client = clients[0]; + const client = clients.find((client) => new URL(client.url).pathname.startsWith('/remotes')) ?? clients[0]; if (client) { await client.navigate(url.href); diff --git a/resources/js/components/remotes-setting.tsx b/resources/js/components/remotes-setting.tsx index ec10e686d..14b8948a8 100644 --- a/resources/js/components/remotes-setting.tsx +++ b/resources/js/components/remotes-setting.tsx @@ -2,13 +2,13 @@ import { router, usePage } from '@inertiajs/react'; import { useState } from 'react'; import { toast } from 'sonner'; import { update } from '@/actions/App/Http/Controllers/UserRemotesController'; -import { Button } from '@/components/ui/button'; import { useRemoteAlertsEnabled, - useRemoteNotificationPermission, + useRemoteNotificationSupport, } from '@/hooks/use-chat'; import type { RemoteAlertChannel } from '@/lib/remote-notifications'; import { setRemoteAlerts } from '@/lib/remote-notifications'; +import { RemotePushValidationError } from '@/lib/remote-push'; import { cn } from '@/lib/utils'; /** @@ -20,13 +20,19 @@ export function RemotesSetting() { const [processing, setProcessing] = useState(false); const alertsEnabled = useRemoteAlertsEnabled(auth.user?.id ?? null); const soundEnabled = useRemoteAlertsEnabled(auth.user?.id ?? null, 'sound'); - const hasPermission = useRemoteNotificationPermission(); + const supported = useRemoteNotificationSupport(); + const offsiteEnabled = useRemoteAlertsEnabled( + auth.user?.id ?? null, + 'offsite', + ); + const [savingAlerts, setSavingAlerts] = useState(false); const saveAlerts = ( enabled: boolean, channel: RemoteAlertChannel = 'notifications', ) => { - if (auth.user === null) return; + if (auth.user === null || savingAlerts) return; + setSavingAlerts(true); void setRemoteAlerts(auth.user.id, enabled, channel) .then((saved) => { if (!saved) @@ -36,11 +42,16 @@ export function RemotesSetting() { : 'Could not save notifications. Check your browser’s site permissions and storage settings.', ); }) - .catch(() => + .catch((error: unknown) => toast.error( - 'Could not enable notifications. Check your browser’s site settings.', + error instanceof RemotePushValidationError + ? error.message + : enabled + ? 'Could not enable off-site notifications. Check this browser’s permissions and try again.' + : 'Could not turn off-site notifications off. Please try again.', ), - ); + ) + .finally(() => setSavingAlerts(false)); }; const toggle = () => { @@ -113,25 +124,14 @@ export function RemotesSetting() { id="remote-alerts-label" className="text-sm font-medium" > - Notifications + On-site notifications

- On this browser, while the site is open. No message - previews. + Show message alerts inside /dev/null.

- +
+
+

+ Off-site notifications +

+

+ Send notifications to this browser or installed app, + including when closed. No message previews. +

+

+ On iPhone and iPad, add /dev/null to your Home Screen + and turn this on from the installed app. +

+
+ +

@@ -164,7 +208,7 @@ export function RemotesSetting() { id="remote-sound-description" className="text-sm text-muted-foreground" > - Play a sound for new messages. + Play a sound for new messages while the site is open.