From 930cd0c04e167dfadf5ce2efad2033466f382418 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 22 Oct 2026 19:53:40 +0000 Subject: [PATCH 1/3] fix: clear stale remote composer validation errors Clear text and attachment errors when editing or sending, without discarding valid attachments. Amp-Thread-ID: https://ampcode.com/threads/T-01a164f2-f78a-744d-9919-ba1ca2383eed Co-authored-by: Pauline Vos --- resources/js/components/chat/conversation-thread.tsx | 11 ++++------- resources/js/hooks/use-image-attachments.ts | 2 ++ tests/Unit/ImageAttachments.test.tsx | 5 +++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index cd46049b5..4dbd9c6f0 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -1390,6 +1390,7 @@ function Composer({ }); voiceNote.discard(); showError(null); + attachments.clearError(); }; const submit = (event?: FormEvent) => { @@ -1418,6 +1419,7 @@ function Composer({ stash.drop(); linkCard.reset(); showError(null); + attachments.clearError(); input.current?.focus(); }; @@ -1610,13 +1612,8 @@ function Composer({ maxLength={maxLength} onValueChange={(value) => { stash.updateBody(value); - - if ( - error !== null && - characterCount(value) < maxLength - ) { - showError(null); - } + showError(null); + attachments.clearError(); if (value.trim() !== '') { pingTyping(target); diff --git a/resources/js/hooks/use-image-attachments.ts b/resources/js/hooks/use-image-attachments.ts index 05db05dd1..147bf7358 100644 --- a/resources/js/hooks/use-image-attachments.ts +++ b/resources/js/hooks/use-image-attachments.ts @@ -13,6 +13,7 @@ export type UseImageAttachmentsReturn = { readonly images: File[]; readonly descriptions: string[]; readonly error: string | null; + readonly clearError: () => void; readonly reading: boolean; readonly isReading: () => boolean; readonly pick: (files: File[]) => Promise; @@ -100,6 +101,7 @@ export function useImageAttachments( images, descriptions, error, + clearError: () => setError(null), reading, isReading: () => isReadingImages(draftKey), pick, diff --git a/tests/Unit/ImageAttachments.test.tsx b/tests/Unit/ImageAttachments.test.tsx index 8b5ae0b18..abe5f4305 100644 --- a/tests/Unit/ImageAttachments.test.tsx +++ b/tests/Unit/ImageAttachments.test.tsx @@ -316,6 +316,11 @@ test('keeps valid images around an oversized selection and displays the error', assert.deepEqual(draft.descriptions, ['', '']); assert.match(attachments.error!, /10 MB/); assert.equal(attachments.isReading(), false); + + attachments.clearError(); + const corrected = render(draft); + assert.equal(corrected.error, null); + assert.deepEqual(corrected.images.map((image) => image.name), ['first.png', 'last.png']); }); test('rejected files do not consume the last available attachment slot', async () => { From 44a2f5f1604f78f4563c1de9085f9ac727d39afa Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 22 Oct 2026 19:53:41 +0000 Subject: [PATCH 2/3] feat: add account-level remote mute preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support one-hour, eight-hour, week-long, permanent, and cleared mutes for direct conversations and orgs through web and signed API endpoints. Expose only the current participant’s preference, preserve unread counts, and include mutes in export and account deletion coverage. Amp-Thread-ID: https://ampcode.com/threads/T-01a164f2-f78a-744d-9919-ba1ca2383eed Co-authored-by: Pauline Vos --- app/Actions/CreateExport.php | 30 +++- app/Actions/UpdateConversationMute.php | 29 +++ app/Enums/ConversationMuteDuration.php | 34 ++++ .../Api/ConversationMuteController.php | 25 +++ .../Controllers/Api/OrgMuteController.php | 25 +++ .../ConversationMuteController.php | 25 +++ app/Http/Controllers/OrgMuteController.php | 25 +++ .../UpdateApiConversationMuteRequest.php | 47 +++++ app/Http/Requests/UpdateApiOrgMuteRequest.php | 43 +++++ .../UpdateConversationMuteRequest.php | 47 +++++ app/Http/Requests/UpdateOrgMuteRequest.php | 43 +++++ app/Http/Resources/ConversationResource.php | 4 +- .../Resources/ConversationThreadResource.php | 1 + app/Http/Resources/OrgThreadResource.php | 1 + app/Models/Conversation.php | 2 + app/Models/ConversationParticipant.php | 2 + app/Queries/ConversationThreadQuery.php | 4 + app/Queries/ConversationsQuery.php | 5 +- ..._at_to_conversation_participants_table.php | 21 +++ routes/api.php | 9 +- routes/web.php | 5 + .../Api/ConversationMuteControllerTest.php | 82 +++++++++ .../ConversationMuteControllerTest.php | 167 ++++++++++++++++++ .../Controllers/ExportControllerTest.php | 22 +++ tests/Unit/Actions/DeleteUserTest.php | 6 +- 25 files changed, 695 insertions(+), 9 deletions(-) create mode 100644 app/Actions/UpdateConversationMute.php create mode 100644 app/Enums/ConversationMuteDuration.php create mode 100644 app/Http/Controllers/Api/ConversationMuteController.php create mode 100644 app/Http/Controllers/Api/OrgMuteController.php create mode 100644 app/Http/Controllers/ConversationMuteController.php create mode 100644 app/Http/Controllers/OrgMuteController.php create mode 100644 app/Http/Requests/UpdateApiConversationMuteRequest.php create mode 100644 app/Http/Requests/UpdateApiOrgMuteRequest.php create mode 100644 app/Http/Requests/UpdateConversationMuteRequest.php create mode 100644 app/Http/Requests/UpdateOrgMuteRequest.php create mode 100644 database/migrations/2026_10_22_120000_add_muted_until_at_to_conversation_participants_table.php create mode 100644 tests/Feature/Controllers/Api/ConversationMuteControllerTest.php create mode 100644 tests/Feature/Controllers/ConversationMuteControllerTest.php diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index e9e006401..4185c9aa1 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -44,6 +44,7 @@ * blocking: Generator, * dismissed_suggestions: Generator, * messages: Generator, + * remote_mutes: array, * notifications: Generator>, * ssh_keys: array, * devices: array, @@ -105,6 +106,20 @@ public function handle(User $user): array 'blocking' => $this->usernames($user->blocking()), 'dismissed_suggestions' => $this->usernames($user->dismissedSuggestions()), 'messages' => $this->messages($user), + 'remote_mutes' => Conversation::query() + ->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id') + ->where('mine.user_id', $user->id) + ->whereNotNull('mine.muted_until_at') + ->select('conversations.*') + ->addSelect('mine.muted_until_at as muted_until_at') + ->with('participants.user') + ->oldest('mine.id') + ->get() + ->map(fn (Conversation $conversation): array => [ + 'to' => $this->recipient($user, $conversation), + 'muted_until_at' => $conversation->muted_until_at?->toIso8601String(), + ]) + ->all(), 'notifications' => $this->notifications($user), 'ssh_keys' => $user->sshKeys()->pluck('fingerprint')->all(), 'devices' => $user->devices()->oldest('approved_at')->get()->map(fn (Device $device): array => [ @@ -217,10 +232,7 @@ private function messages(User $user): Generator ->whereIn('id', ConversationParticipant::query()->select('conversation_id')->where('user_id', $user->id)) ->with('participants.user') ->get() - ->mapWithKeys(fn (Conversation $conversation): array => [$conversation->id => $conversation->isOrg() - ? 'org: '.($conversation->name ?? $conversation->id) - : $conversation->participants->firstWhere('user_id', '!=', $user->id)?->user->username, - ]); + ->mapWithKeys(fn (Conversation $conversation): array => [$conversation->id => $this->recipient($user, $conversation)]); foreach (Message::query()->where('user_id', $user->id)->whereNull('event')->with('voice')->lazyById(500) as $message) { yield [ @@ -232,6 +244,16 @@ private function messages(User $user): Generator } } + /** + * The other person in a conversation, or the org it is. + */ + private function recipient(User $user, Conversation $conversation): ?string + { + return $conversation->isOrg() + ? 'org: '.($conversation->name ?? $conversation->id) + : $conversation->participants->firstWhere('user_id', '!=', $user->id)?->user->username; + } + /** * @param BelongsToMany $users * @return Generator diff --git a/app/Actions/UpdateConversationMute.php b/app/Actions/UpdateConversationMute.php new file mode 100644 index 000000000..d98a1a2d2 --- /dev/null +++ b/app/Actions/UpdateConversationMute.php @@ -0,0 +1,29 @@ +until($user->timezone); + + ConversationParticipant::query() + ->where('user_id', $user->id) + ->where('conversation_id', $conversation->id) + ->update(['muted_until_at' => $until]); + + return $until; + } +} diff --git a/app/Enums/ConversationMuteDuration.php b/app/Enums/ConversationMuteDuration.php new file mode 100644 index 000000000..a3b5411f2 --- /dev/null +++ b/app/Enums/ConversationMuteDuration.php @@ -0,0 +1,34 @@ + CarbonImmutable::now()->startOfSecond()->addHour(), + self::Tomorrow => CarbonImmutable::now($timezone ?? 'UTC')->addDay()->startOfDay()->utc(), + self::Week => CarbonImmutable::now()->startOfSecond()->addWeek(), + self::Forever => CarbonImmutable::parse('9999-12-31T00:00:00Z'), + self::Off => null, + }; + } +} diff --git a/app/Http/Controllers/Api/ConversationMuteController.php b/app/Http/Controllers/Api/ConversationMuteController.php new file mode 100644 index 000000000..ae338eb7a --- /dev/null +++ b/app/Http/Controllers/Api/ConversationMuteController.php @@ -0,0 +1,25 @@ +handle($user, Conversation::betweenOrFail($user, $partner), $request->duration()); + + return response()->json(['muted_until_at' => $until?->toJSON()]); + } +} diff --git a/app/Http/Controllers/Api/OrgMuteController.php b/app/Http/Controllers/Api/OrgMuteController.php new file mode 100644 index 000000000..3954ea5d7 --- /dev/null +++ b/app/Http/Controllers/Api/OrgMuteController.php @@ -0,0 +1,25 @@ +handle($user, $org, $request->duration()); + + return response()->json(['muted_until_at' => $until?->toJSON()]); + } +} diff --git a/app/Http/Controllers/ConversationMuteController.php b/app/Http/Controllers/ConversationMuteController.php new file mode 100644 index 000000000..31e7ec34c --- /dev/null +++ b/app/Http/Controllers/ConversationMuteController.php @@ -0,0 +1,25 @@ +handle($user, Conversation::betweenOrFail($user, $partner), $request->duration()); + + return response()->json(['muted_until_at' => $until?->toJSON()]); + } +} diff --git a/app/Http/Controllers/OrgMuteController.php b/app/Http/Controllers/OrgMuteController.php new file mode 100644 index 000000000..c56c26ff8 --- /dev/null +++ b/app/Http/Controllers/OrgMuteController.php @@ -0,0 +1,25 @@ +handle($user, $org, $request->duration()); + + return response()->json(['muted_until_at' => $until?->toJSON()]); + } +} diff --git a/app/Http/Requests/UpdateApiConversationMuteRequest.php b/app/Http/Requests/UpdateApiConversationMuteRequest.php new file mode 100644 index 000000000..173aade97 --- /dev/null +++ b/app/Http/Requests/UpdateApiConversationMuteRequest.php @@ -0,0 +1,47 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + return $user->canMessage($partner) && Conversation::between($user, $partner) instanceof Conversation + ? Response::allow() + : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'duration' => ['required', 'string', Rule::enum(ConversationMuteDuration::class)], + ]; + } + + public function duration(): ConversationMuteDuration + { + return ConversationMuteDuration::from($this->string('duration')->value()); + } +} diff --git a/app/Http/Requests/UpdateApiOrgMuteRequest.php b/app/Http/Requests/UpdateApiOrgMuteRequest.php new file mode 100644 index 000000000..661461931 --- /dev/null +++ b/app/Http/Requests/UpdateApiOrgMuteRequest.php @@ -0,0 +1,43 @@ +user(); + assert($user instanceof User); + + $org = $this->route('org'); + assert($org instanceof Conversation); + + return $user->remotes_enabled && $org->roleOf($user) instanceof OrgRole ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'duration' => ['required', 'string', Rule::enum(ConversationMuteDuration::class)], + ]; + } + + public function duration(): ConversationMuteDuration + { + return ConversationMuteDuration::from($this->string('duration')->value()); + } +} diff --git a/app/Http/Requests/UpdateConversationMuteRequest.php b/app/Http/Requests/UpdateConversationMuteRequest.php new file mode 100644 index 000000000..860a5acd6 --- /dev/null +++ b/app/Http/Requests/UpdateConversationMuteRequest.php @@ -0,0 +1,47 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + return $user->canMessage($partner) && Conversation::between($user, $partner) instanceof Conversation + ? Response::allow() + : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'duration' => ['required', 'string', Rule::enum(ConversationMuteDuration::class)], + ]; + } + + public function duration(): ConversationMuteDuration + { + return ConversationMuteDuration::from($this->string('duration')->value()); + } +} diff --git a/app/Http/Requests/UpdateOrgMuteRequest.php b/app/Http/Requests/UpdateOrgMuteRequest.php new file mode 100644 index 000000000..7a12a682a --- /dev/null +++ b/app/Http/Requests/UpdateOrgMuteRequest.php @@ -0,0 +1,43 @@ +user(); + assert($user instanceof User); + + $org = $this->route('org'); + assert($org instanceof Conversation); + + return $user->remotes_enabled && $org->roleOf($user) instanceof OrgRole ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'duration' => ['required', 'string', Rule::enum(ConversationMuteDuration::class)], + ]; + } + + public function duration(): ConversationMuteDuration + { + return ConversationMuteDuration::from($this->string('duration')->value()); + } +} diff --git a/app/Http/Resources/ConversationResource.php b/app/Http/Resources/ConversationResource.php index 13faa619e..0b951fee9 100644 --- a/app/Http/Resources/ConversationResource.php +++ b/app/Http/Resources/ConversationResource.php @@ -12,8 +12,8 @@ /** * A row of the web's inbox: the partner, or an org's name and members, the last message, how many messages from - * others the user hasn't pulled yet (`ahead`), how far the partner has pulled when both of them share pulls, and - * whether the user stashed it. + * others the user hasn't pulled yet (`ahead`), how far the partner has pulled when both of them share pulls, whether + * the user stashed it, and until when they muted it. * * @phpstan-import-type ConversationSummary from ConversationsQuery */ diff --git a/app/Http/Resources/ConversationThreadResource.php b/app/Http/Resources/ConversationThreadResource.php index e081311ee..6cb0fc34d 100644 --- a/app/Http/Resources/ConversationThreadResource.php +++ b/app/Http/Resources/ConversationThreadResource.php @@ -27,6 +27,7 @@ * partner_read_at: DirectThread['partner_read_at'], * partner_typing: DirectThread['partner_typing'], * is_archived: DirectThread['is_archived'], + * muted_until_at: DirectThread['muted_until_at'], * access: DirectThread['access'] * } */ diff --git a/app/Http/Resources/OrgThreadResource.php b/app/Http/Resources/OrgThreadResource.php index e57964816..3053117bf 100644 --- a/app/Http/Resources/OrgThreadResource.php +++ b/app/Http/Resources/OrgThreadResource.php @@ -26,6 +26,7 @@ * members: OrgThread['members'], * typing: OrgThread['typing'], * is_archived: OrgThread['is_archived'], + * muted_until_at: OrgThread['muted_until_at'], * role: OrgThread['role'], * access: OrgThread['access'] * } diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php index ed04a3205..3cfab4dce 100644 --- a/app/Models/Conversation.php +++ b/app/Models/Conversation.php @@ -36,6 +36,7 @@ * @property-read int $partner_read_through_id * @property-read CarbonInterface|null $partner_read_at * @property-read CarbonInterface|null $archived_at + * @property-read CarbonInterface|null $muted_until_at */ final class Conversation extends Model { @@ -109,6 +110,7 @@ public function casts(): array 'partner_read_through_id' => 'integer', 'partner_read_at' => 'datetime', 'archived_at' => 'datetime', + 'muted_until_at' => 'datetime', ]; } diff --git a/app/Models/ConversationParticipant.php b/app/Models/ConversationParticipant.php index 38d752e63..f5442267b 100644 --- a/app/Models/ConversationParticipant.php +++ b/app/Models/ConversationParticipant.php @@ -24,6 +24,7 @@ * @property-read User $user * @property-read CarbonInterface|null $read_at * @property-read CarbonInterface|null $archived_at + * @property-read CarbonInterface|null $muted_until_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at */ @@ -57,6 +58,7 @@ public function casts(): array 'read_through_id' => 'integer', 'read_at' => 'datetime', 'archived_at' => 'datetime', + 'muted_until_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Queries/ConversationThreadQuery.php b/app/Queries/ConversationThreadQuery.php index 6d20204b1..9d9a4e713 100644 --- a/app/Queries/ConversationThreadQuery.php +++ b/app/Queries/ConversationThreadQuery.php @@ -30,6 +30,7 @@ * partner_read_at: string|null, * partner_typing: bool, * is_archived: bool, + * muted_until_at: string|null, * access: PushAccess * } * @phpstan-type OrgThread array{ @@ -41,6 +42,7 @@ * members: Collection, * typing: array, * is_archived: bool, + * muted_until_at: string|null, * role: OrgRole|null, * access: PushAccess * } @@ -78,6 +80,7 @@ public function get(User $user, User $partner, ?int $after = null, ?int $before 'partner_read_at' => $sharesReads ? $theirs->read_at?->toJSON() : null, 'partner_typing' => $access === PushAccess::Open && $conversation instanceof Conversation && Cache::has(UpdateConversationTyping::key($partner, $conversation)), 'is_archived' => $mine?->archived_at !== null, + 'muted_until_at' => $mine?->muted_until_at?->isFuture() ? $mine->muted_until_at->toJSON() : null, 'access' => $access, ]; } @@ -125,6 +128,7 @@ public function org(User $user, Conversation $org, ?int $after = null, ?int $bef ->values() ->all(), 'is_archived' => $mine->archived_at !== null, + 'muted_until_at' => $mine->muted_until_at?->isFuture() ? $mine->muted_until_at->toJSON() : null, 'role' => $mine->role, 'access' => PushAccess::Open, ]; diff --git a/app/Queries/ConversationsQuery.php b/app/Queries/ConversationsQuery.php index 567b71a30..0ec363aea 100644 --- a/app/Queries/ConversationsQuery.php +++ b/app/Queries/ConversationsQuery.php @@ -28,7 +28,8 @@ * ahead: int, * partner_read_through_id: int, * partner_read_at: string|null, - * is_archived: bool + * is_archived: bool, + * muted_until_at: string|null * } */ final readonly class ConversationsQuery @@ -84,6 +85,7 @@ public function get(User $user, InboxTab $tab = InboxTab::Open, string $term = ' 'theirs.read_at as partner_read_at', 'mine.last_message_at as last_message_at', 'mine.archived_at as archived_at', + 'mine.muted_until_at as muted_until_at', ]) ->selectSub( Message::query() @@ -171,6 +173,7 @@ private function summary(User $user, Conversation $conversation): array 'partner_read_through_id' => $sharesReads ? $conversation->partner_read_through_id : 0, 'partner_read_at' => $sharesReads ? $conversation->partner_read_at?->toJSON() : null, 'is_archived' => $conversation->archived_at !== null, + 'muted_until_at' => $conversation->muted_until_at?->isFuture() ? $conversation->muted_until_at->toJSON() : null, ]; } diff --git a/database/migrations/2026_10_22_120000_add_muted_until_at_to_conversation_participants_table.php b/database/migrations/2026_10_22_120000_add_muted_until_at_to_conversation_participants_table.php new file mode 100644 index 000000000..1d0cb9c57 --- /dev/null +++ b/database/migrations/2026_10_22_120000_add_muted_until_at_to_conversation_participants_table.php @@ -0,0 +1,21 @@ +timestamp('muted_until_at')->nullable(); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 915587c51..c7c57ea7f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\ConversationDropController; use App\Http\Controllers\Api\ConversationMergeController; use App\Http\Controllers\Api\ConversationMessageController; +use App\Http\Controllers\Api\ConversationMuteController; use App\Http\Controllers\Api\ConversationReadController; use App\Http\Controllers\Api\ConversationTypingController; use App\Http\Controllers\Api\CurrentUserController; @@ -28,6 +29,7 @@ use App\Http\Controllers\Api\OrgArchiveController; use App\Http\Controllers\Api\OrgController; use App\Http\Controllers\Api\OrgMessageController; +use App\Http\Controllers\Api\OrgMuteController; use App\Http\Controllers\Api\OrgReadController; use App\Http\Controllers\Api\OrgTypingController; use App\Http\Controllers\Api\RemoteCandidateController; @@ -173,7 +175,7 @@ Route::post('users/{user:username}/block', [UserBlockController::class, 'store'])->name('api-user-block.store'); Route::delete('users/{user:username}/block', [UserBlockController::class, 'destroy'])->name('api-user-block.destroy'); - // Remotes: stashing a conversation or an org, and merging or dropping a fetched request... + // Remotes: stashing or muting a conversation or an org, and merging or dropping a fetched request... Route::middleware('throttle:conversations')->group(function (): void { Route::post('remotes/@{partner:username}/archive', [ConversationArchiveController::class, 'store']) ->name('api-conversation-archive.store'); @@ -185,6 +187,11 @@ Route::delete('remotes/orgs/{org}/archive', [OrgArchiveController::class, 'destroy']) ->whereUuid('org') ->name('api-org-archive.destroy'); + Route::put('remotes/@{partner:username}/mute', [ConversationMuteController::class, 'update']) + ->name('api-conversation-mute.update'); + Route::put('remotes/orgs/{org}/mute', [OrgMuteController::class, 'update']) + ->whereUuid('org') + ->name('api-org-mute.update'); Route::post('remotes/@{partner:username}/merge', [ConversationMergeController::class, 'store']) ->name('api-conversation-merge.store'); Route::post('remotes/@{partner:username}/drop', [ConversationDropController::class, 'store']) diff --git a/routes/web.php b/routes/web.php index 5ef875a03..4b0febf60 100644 --- a/routes/web.php +++ b/routes/web.php @@ -35,6 +35,7 @@ use App\Http\Controllers\ConversationDropController; use App\Http\Controllers\ConversationMergeController; use App\Http\Controllers\ConversationMessageController; +use App\Http\Controllers\ConversationMuteController; use App\Http\Controllers\ConversationReadController; use App\Http\Controllers\ConversationTypingController; use App\Http\Controllers\DeviceController; @@ -65,6 +66,7 @@ use App\Http\Controllers\OrgController; use App\Http\Controllers\OrgMemberController; use App\Http\Controllers\OrgMessageController; +use App\Http\Controllers\OrgMuteController; use App\Http\Controllers\OrgReadController; use App\Http\Controllers\OrgTypingController; use App\Http\Controllers\PrivacyController; @@ -331,6 +333,7 @@ Route::put('remotes/orgs/{org}/typing', [OrgTypingController::class, 'update'])->whereUuid('org')->name('org-typing.update'); Route::post('remotes/orgs/{org}/archive', [OrgArchiveController::class, 'store'])->whereUuid('org')->name('org-archive.store'); Route::delete('remotes/orgs/{org}/archive', [OrgArchiveController::class, 'destroy'])->whereUuid('org')->name('org-archive.destroy'); + Route::put('remotes/orgs/{org}/mute', [OrgMuteController::class, 'update'])->whereUuid('org')->name('org-mute.update'); Route::post('remotes/orgs/{org}/members', [OrgMemberController::class, 'store'])->whereUuid('org')->middleware([PreventPostingInTimeout::class, 'throttle:messages'])->name('org-member.store'); Route::put('remotes/orgs/{org}/members/{member:username}', [OrgMemberController::class, 'update'])->whereUuid('org')->withoutScopedBindings()->name('org-member.update'); Route::delete('remotes/orgs/{org}/members/{member:username}', [OrgMemberController::class, 'destroy'])->whereUuid('org')->withoutScopedBindings()->name('org-member.destroy'); @@ -338,6 +341,8 @@ ->name('conversation-archive.store'); Route::delete('remotes/@{partner:username}/archive', [ConversationArchiveController::class, 'destroy']) ->name('conversation-archive.destroy'); + Route::put('remotes/@{partner:username}/mute', [ConversationMuteController::class, 'update']) + ->name('conversation-mute.update'); Route::get('remotes/messages/{message}/link-preview', [MessageLinkPreviewController::class, 'show']) ->whereNumber('message') ->name('message-link-preview.show'); diff --git a/tests/Feature/Controllers/Api/ConversationMuteControllerTest.php b/tests/Feature/Controllers/Api/ConversationMuteControllerTest.php new file mode 100644 index 000000000..9906d2dd7 --- /dev/null +++ b/tests/Feature/Controllers/Api/ConversationMuteControllerTest.php @@ -0,0 +1,82 @@ + true, 'remotes.everyone' => true]); + + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('mutes a conversation for the user only, shows it in their inbox and thread, and unmutes it', function (string $duration, string $expected): void { + $this->travelTo(now()->parse('2026-01-02T02:00:00Z')); + $this->user->update(['timezone' => 'America/Los_Angeles']); + $nuno = User::factory()->withRemotes()->create(['timezone' => 'Asia/Kathmandu']); + sendChatMessage($nuno, $this->user); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-mute.update', $nuno), ['duration' => $duration]) + ->assertOk() + ->assertExactJson(['muted_until_at' => $expected]); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation.index'))->assertJsonPath('data.0.muted_until_at', $expected); + signedApiRequest($this->keyPair, 'GET', route('api-conversation-message.index', $nuno))->assertJsonPath('data.muted_until_at', $expected); + $this->actingAs($nuno)->getJson(route('inbox.index'))->assertJsonPath('conversations.0.muted_until_at', null); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-mute.update', $nuno), ['duration' => 'off']) + ->assertExactJson(['muted_until_at' => null]); +})->with([ + 'an hour' => ['hour', '2026-01-02T03:00:00.000000Z'], + 'the signed users next local midnight' => ['tomorrow', '2026-01-02T08:00:00.000000Z'], +]); + +it('mutes an org for the user only', function (string $duration, string $expected): void { + $this->travelTo(now()->parse('2026-03-08T05:00:00Z')); + $this->user->update(['timezone' => 'America/New_York']); + [$nuno] = followersOf($this->user, 1); + $nuno->update(['timezone' => 'Asia/Kathmandu']); + $this->user->following()->attach($nuno); + $org = createOrg($nuno, [$this->user]); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-mute.update', $org), ['duration' => $duration]) + ->assertOk() + ->assertExactJson(['muted_until_at' => $expected]); + + signedApiRequest($this->keyPair, 'GET', route('api-org-message.index', $org))->assertJsonPath('data.muted_until_at', $expected); + $this->actingAs($nuno)->getJson(route('org-message.index', $org))->assertJsonPath('muted_until_at', null); +})->with([ + 'forever' => ['forever', '9999-12-31T00:00:00.000000Z'], + 'the signed members next local midnight across DST' => ['tomorrow', '2026-03-09T04:00:00.000000Z'], +]); + +it('rejects durations it does not offer', function (): void { + $nuno = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $this->user); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-mute.update', $nuno), ['duration' => 'month']) + ->assertJsonValidationErrors('duration'); + signedApiRequest($this->keyPair, 'PUT', route('api-org-mute.update', createOrg($this->user, followersOf($this->user, 1))), []) + ->assertJsonValidationErrors('duration'); +}); + +it('has nothing to mute before the conversation starts, or with people the user cannot message', function (): void { + $stranger = User::factory()->withRemotes()->create(); + $blocker = User::factory()->withRemotes()->create(); + sendChatMessage($blocker, $this->user); + $blocker->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-mute.update', $stranger), ['duration' => 'hour'])->assertNotFound(); + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-mute.update', $blocker), ['duration' => 'hour'])->assertNotFound(); + + expect(Conversation::query()->count())->toBe(1); +}); + +it('hides muting an org from people outside it', function (): void { + $maintainer = User::factory()->withRemotes()->create(); + $org = createOrg($maintainer, followersOf($maintainer, 1)); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-mute.update', $org), ['duration' => 'hour'])->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/ConversationMuteControllerTest.php b/tests/Feature/Controllers/ConversationMuteControllerTest.php new file mode 100644 index 000000000..354f61993 --- /dev/null +++ b/tests/Feature/Controllers/ConversationMuteControllerTest.php @@ -0,0 +1,167 @@ + true, 'remotes.everyone' => true]); +}); + +it('mutes a conversation for you only, until the chosen duration ends', function (string $duration, Closure $until): void { + $this->freezeSecond(); + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $mark, 'ship it'); + $expected = $until()->toJSON(); + + $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), ['duration' => $duration]) + ->assertOk() + ->assertExactJson(['muted_until_at' => $expected]); + + $this->getJson(route('inbox.index'))->assertJsonPath('conversations.0.muted_until_at', $expected); + $this->getJson(route('conversation-message.index', $nuno))->assertJsonPath('muted_until_at', $expected); + + $this->actingAs($nuno)->getJson(route('inbox.index'))->assertJsonPath('conversations.0.muted_until_at', null); + $this->getJson(route('conversation-message.index', $mark))->assertJsonPath('muted_until_at', null); +})->with([ + 'an hour' => ['hour', fn (): CarbonInterface => now()->addHour()], + 'a week' => ['week', fn (): CarbonInterface => now()->addWeek()], + 'forever' => ['forever', fn (): CarbonInterface => now()->parse('9999-12-31T00:00:00Z')], +]); + +it('mutes until the next midnight in your account timezone and persists the same UTC expiration', function (?string $timezone, string $now, string $expected): void { + $this->travelTo(now()->parse($now)); + $mark = User::factory()->withRemotes()->create(['timezone' => $timezone]); + $nuno = User::factory()->withRemotes()->create(['timezone' => 'Pacific/Honolulu']); + sendChatMessage($nuno, $mark); + + $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), ['duration' => 'tomorrow']) + ->assertOk() + ->assertExactJson(['muted_until_at' => $expected]); + + $this->getJson(route('inbox.index'))->assertJsonPath('conversations.0.muted_until_at', $expected); + $this->getJson(route('conversation-message.index', $nuno))->assertJsonPath('muted_until_at', $expected); +})->with([ + 'local date ahead of UTC with fractional offset' => ['Asia/Kathmandu', '2026-01-01T20:00:00Z', '2026-01-02T18:15:00.000000Z'], + 'local date behind UTC' => ['America/Los_Angeles', '2026-01-02T02:00:00Z', '2026-01-02T08:00:00.000000Z'], + 'spring forward midnight is 23 hours away' => ['America/New_York', '2026-03-08T05:00:00Z', '2026-03-09T04:00:00.000000Z'], + 'fall back midnight is 25 hours away' => ['America/New_York', '2026-11-01T04:00:00Z', '2026-11-02T05:00:00.000000Z'], + 'no account timezone falls back to UTC' => [null, '2026-01-01T20:00:00Z', '2026-01-02T00:00:00.000000Z'], +]); + +it('unmutes a conversation', function (): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $mark, 'ship it'); + $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), ['duration' => 'forever'])->assertOk(); + + $this->putJson(route('conversation-mute.update', $nuno), ['duration' => 'off']) + ->assertOk() + ->assertExactJson(['muted_until_at' => null]); + + $this->getJson(route('inbox.index'))->assertJsonPath('conversations.0.muted_until_at', null); + $this->getJson(route('conversation-message.index', $nuno))->assertJsonPath('muted_until_at', null); +}); + +it('stops showing a mute once it ends', function (): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $mark, 'ship it'); + $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), ['duration' => 'hour'])->assertOk(); + + $this->travel(1)->hours(); + + $this->getJson(route('inbox.index'))->assertJsonPath('conversations.0.muted_until_at', null); + $this->getJson(route('conversation-message.index', $nuno))->assertJsonPath('muted_until_at', null); +}); + +it('keeps a mute through new messages without changing what is unread', function (): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $mark, 'ship it'); + $muted = $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), ['duration' => 'week'])->json('muted_until_at'); + $this->actingAs($mark)->postJson(route('conversation-archive.store', $nuno))->assertNoContent(); + + sendChatMessage($nuno, $mark, 'one more thing'); + + $this->actingAs($mark)->getJson(route('inbox.index')) + ->assertJsonPath('unread', 2) + ->assertJsonPath('conversations.0.ahead', 2) + ->assertJsonPath('conversations.0.is_archived', false) + ->assertJsonPath('conversations.0.muted_until_at', $muted); +}); + +it('mutes an org for you only, and unmutes it', function (string $duration, string $expected): void { + $this->travelTo(now()->parse('2026-01-01T20:00:00Z')); + $mark = User::factory()->withRemotes()->create(['timezone' => 'Pacific/Honolulu']); + [$nuno] = followersOf($mark, 1); + $nuno->update(['timezone' => 'Asia/Kathmandu']); + $org = createOrg($mark, [$nuno]); + + $this->actingAs($nuno)->putJson(route('org-mute.update', $org), ['duration' => $duration]) + ->assertOk() + ->assertExactJson(['muted_until_at' => $expected]); + + $this->getJson(route('inbox.index'))->assertJsonPath('conversations.0.muted_until_at', $expected); + $this->getJson(route('org-message.index', $org))->assertJsonPath('muted_until_at', $expected); + $this->actingAs($mark)->getJson(route('org-message.index', $org))->assertJsonPath('muted_until_at', null); + + $this->actingAs($nuno)->putJson(route('org-mute.update', $org), ['duration' => 'off'])->assertExactJson(['muted_until_at' => null]); + + $this->getJson(route('org-message.index', $org))->assertJsonPath('muted_until_at', null); +})->with([ + 'an hour' => ['hour', '2026-01-01T21:00:00.000000Z'], + 'the members next local midnight, not the maintainers' => ['tomorrow', '2026-01-02T18:15:00.000000Z'], +]); + +it('rejects durations it does not offer', function (array $payload): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $mark, 'ship it'); + $org = createOrg($mark, followersOf($mark, 1)); + + $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), $payload)->assertJsonValidationErrors('duration'); + $this->putJson(route('org-mute.update', $org), $payload)->assertJsonValidationErrors('duration'); +})->with([ + 'missing' => [[]], + 'empty' => [['duration' => '']], + 'unknown' => [['duration' => 'day']], + 'removed eight-hour option' => [['duration' => 'eight_hours']], + 'minutes' => [['duration' => 60]], + 'list' => [['duration' => ['hour']]], +]); + +it('has nothing to mute before the conversation starts', function (): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + + $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), ['duration' => 'hour'])->assertNotFound(); + + expect(Conversation::query()->count())->toBe(0); +}); + +it('hides muting from people you cannot message', function (): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $mark, 'ship it'); + $nuno->blocking()->attach($mark); + + $this->actingAs($mark)->putJson(route('conversation-mute.update', $nuno), ['duration' => 'hour'])->assertNotFound(); +}); + +it('hides muting an org from people outside it', function (): void { + $mark = User::factory()->withRemotes()->create(); + $org = createOrg($mark, followersOf($mark, 1)); + + $this->actingAs(User::factory()->withRemotes()->create()) + ->putJson(route('org-mute.update', $org), ['duration' => 'hour']) + ->assertNotFound(); + $this->actingAs($mark)->getJson(route('org-message.index', $org))->assertJsonPath('muted_until_at', null); +}); + +it('requires authentication', function (): void { + $this->putJson(route('conversation-mute.update', User::factory()->withRemotes()->create()), ['duration' => 'hour'])->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index bf3d07932..5ea971219 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -126,6 +126,7 @@ 'blocking' => [], 'dismissed_suggestions' => [], 'messages' => [], + 'remote_mutes' => [], 'notifications' => [], 'ssh_keys' => [], 'devices' => [], @@ -160,6 +161,27 @@ ]); }); +it('exports the remotes the user muted, without the mutes of others', function (): void { + config(['remotes.requests' => true, 'remotes.everyone' => true]); + $user = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(['username' => 'nunomaduro']); + $taylor = User::factory()->withRemotes()->create(); + sendChatMessage($nuno, $user); + sendChatMessage($taylor, $user); + $org = createOrg($user, followersOf($user, 1), 'dev/null core'); + $this->actingAs($user)->putJson(route('conversation-mute.update', $nuno), ['duration' => 'week'])->assertOk(); + $this->putJson(route('org-mute.update', $org), ['duration' => 'forever'])->assertOk(); + $this->actingAs($taylor)->putJson(route('conversation-mute.update', $user), ['duration' => 'forever'])->assertOk(); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['remote_mutes'])->toBe([ + ['to' => 'nunomaduro', 'muted_until_at' => now()->startOfSecond()->addWeek()->toIso8601String()], + ['to' => 'org: dev/null core', 'muted_until_at' => '9999-12-31T00:00:00+00:00'], + ]); +}); + it('exports the pinned commit id', function (): void { $user = User::factory()->create(); $commit = Commit::factory()->for($user)->create(); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 1da44b144..0222e2599 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -9,6 +9,7 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Models\Conversation; +use App\Models\ConversationParticipant; use App\Models\Device; use App\Models\LinkPreview; use App\Models\Message; @@ -329,9 +330,12 @@ } $this->actingAs($former)->deleteJson(route('org-member.destroy', [$emptied, $former]))->assertNoContent(); + $this->actingAs($user)->putJson(route('org-mute.update', $kept), ['duration' => 'forever'])->assertOk(); + $this->actingAs($former)->putJson(route('org-mute.update', $kept), ['duration' => 'week'])->assertOk(); resolve(DeleteUser::class)->handle($user); expect(Conversation::query()->pluck('id')->all())->toBe([$kept]) - ->and(Storage::disk()->allFiles())->toBe([$other->path]); + ->and(Storage::disk()->allFiles())->toBe([$other->path]) + ->and(ConversationParticipant::query()->whereNotNull('muted_until_at')->pluck('user_id')->all())->toBe([$former->id]); }); From d2b3bcf6e7f676c912e86dbbde20ecf889ba47b7 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 22 Oct 2026 19:54:37 +0000 Subject: [PATCH 3/3] feat: add browser notifications and mute controls for remotes Enable alerts by default in account profile settings, subject to browser permission. Play the supplied sound, keep notification text private, and share focused reading state and a ten-second cooldown across tabs without replaying suppressed arrivals. Track incoming messages independently of org events. Add timed mute and unmute controls, and avoid marking messages read in hidden or unfocused windows. Co-authored-by: Pauline Vos Amp-Thread-ID: https://ampcode.com/threads/T-01a164f2-f78a-744d-9919-ba1ca2383eed --- KNOWLEDGE_BASE.md | 4 + PRODUCT.md | 2 +- app/Http/Controllers/InboxController.php | 1 + app/Http/Requests/ListInboxRequest.php | 6 + app/Http/Resources/ConversationResource.php | 5 +- app/Models/Conversation.php | 2 + app/Queries/ConversationsQuery.php | 37 ++ public/sounds/remote.wav | Bin 0 -> 33558 bytes public/sw.js | 18 + .../components/chat/conversation-actions.tsx | 104 ++- .../js/components/chat/conversation-list.tsx | 1 + .../components/chat/conversation-thread.tsx | 17 +- resources/js/components/remotes-setting.tsx | 113 ++++ resources/js/hooks/use-chat.ts | 38 ++ resources/js/layouts/app-layout.tsx | 2 + resources/js/lib/chat.ts | 204 +++++- resources/js/lib/remote-notifications.ts | 302 +++++++++ resources/js/pages/conversation/index.tsx | 5 +- resources/js/types/conversation.ts | 4 + .../Api/ConversationControllerTest.php | 42 ++ .../Controllers/InboxControllerTest.php | 229 ++++++- tests/Unit/RemoteNotifications.test.tsx | 594 ++++++++++++++++++ tests/Unit/RemotePreferences.test.tsx | 160 +++++ tests/draft-images.config.ts | 2 + tests/tsconfig.json | 2 + 25 files changed, 1860 insertions(+), 34 deletions(-) create mode 100644 public/sounds/remote.wav create mode 100644 resources/js/lib/remote-notifications.ts create mode 100644 tests/Unit/RemoteNotifications.test.tsx create mode 100644 tests/Unit/RemotePreferences.test.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 571c320eb..e673cf6f5 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -323,6 +323,10 @@ 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 and share a 10-second cooldown across remotes and tabs so a burst stays quiet. While the Remotes page is visible and focused, all remote alerts are silenced across tabs; other conversations keep their unread counts. Outside that page, alerts skip the remote you are actively reading in the dock. Switching away lets new messages alert again, without replaying messages suppressed while you were on Remotes. Existing unread messages do not trigger alerts when you open the site. + +To silence one direct remote or org, open its **…** menu and choose **Mute**, below **Stash**, then **For one hour**, **Until tomorrow**, **For one week**, or **Forever**. **Until tomorrow** ends at the next midnight in your account's timezone, or UTC if no timezone is set. The mute follows your account across devices, does not tell other members, and suppresses browser alerts and sound without hiding messages or changing unread counts. New messages do not cancel a mute. Timed mutes expire automatically; choose **Unmute** in the same menu to end any mute early. + Livestream history: the notification requirements on [day one at 5:28:53](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=19733s) explicitly included removing stale activity after an unfollow or deleted reply. The stated goal was to avoid both spam and notifications that “no longer make sense.” ### Remotes: direct messages diff --git a/PRODUCT.md b/PRODUCT.md index 308b227f5..7e46806c6 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 only. +- **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. - **Search:** developers by name, commits by meaning. - **Safety:** mute, block, reports, and moderation. - **Bots:** Tom answers questions about /dev/null from the knowledge base, the public repository, and aggregate community statistics; World answers "hello world". They use @tom and @world until those GitHub owners sign in, then keep working as @devnull-tom and @devnull-world. diff --git a/app/Http/Controllers/InboxController.php b/app/Http/Controllers/InboxController.php index 2f7545e74..596077f95 100644 --- a/app/Http/Controllers/InboxController.php +++ b/app/Http/Controllers/InboxController.php @@ -15,6 +15,7 @@ public function index(ListInboxRequest $request, #[CurrentUser] User $user, ConversationsQuery $conversations): JsonResponse { return response()->json([ + ...($request->notificationBaseline() ? ['notification_baseline' => $conversations->notificationBaseline($user)] : []), ...$conversations->get($user, $request->tab(), $request->term(), $request->beforeId()), 'unread' => $conversations->unreadCount($user), 'requests' => $conversations->requestCount($user), diff --git a/app/Http/Requests/ListInboxRequest.php b/app/Http/Requests/ListInboxRequest.php index bea174178..25705b9f4 100644 --- a/app/Http/Requests/ListInboxRequest.php +++ b/app/Http/Requests/ListInboxRequest.php @@ -30,9 +30,15 @@ public function rules(): array 'tab' => ['nullable', config('remotes.requests') ? Rule::enum(InboxTab::class) : Rule::enum(InboxTab::class)->except(InboxTab::Fetched)], 'q' => ['nullable', 'string', 'max:100'], 'before' => ['nullable', 'uuid'], + 'notification_baseline' => ['sometimes', 'boolean'], ]; } + public function notificationBaseline(): bool + { + return $this->boolean('notification_baseline'); + } + public function tab(): InboxTab { return $this->enum('tab', InboxTab::class) ?? InboxTab::Open; diff --git a/app/Http/Resources/ConversationResource.php b/app/Http/Resources/ConversationResource.php index 0b951fee9..869c7c63f 100644 --- a/app/Http/Resources/ConversationResource.php +++ b/app/Http/Resources/ConversationResource.php @@ -12,8 +12,9 @@ /** * A row of the web's inbox: the partner, or an org's name and members, the last message, how many messages from - * others the user hasn't pulled yet (`ahead`), how far the partner has pulled when both of them share pulls, whether - * the user stashed it, and until when they muted it. + * others the user hasn't pulled yet (`ahead`), the id of the newest message from others, read or not and never an + * org event (`last_incoming_message_id`), how far the partner has pulled when both of them share pulls, whether the + * user stashed it, and until when they muted it. * * @phpstan-import-type ConversationSummary from ConversationsQuery */ diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php index 3cfab4dce..198d8c0c2 100644 --- a/app/Models/Conversation.php +++ b/app/Models/Conversation.php @@ -31,6 +31,7 @@ * @property-read User|null $partner * @property-read string $partner_id * @property-read int $ahead + * @property-read int|null $last_incoming_message_id * @property-read CarbonInterface|null $last_message_at * @property-read RemoteStatus $partner_status * @property-read int $partner_read_through_id @@ -105,6 +106,7 @@ public function casts(): array 'updated_at' => 'datetime', 'partner_id' => 'string', 'ahead' => 'integer', + 'last_incoming_message_id' => 'integer', 'last_message_at' => 'datetime', 'partner_status' => RemoteStatus::class, 'partner_read_through_id' => 'integer', diff --git a/app/Queries/ConversationsQuery.php b/app/Queries/ConversationsQuery.php index 0ec363aea..02807b39e 100644 --- a/app/Queries/ConversationsQuery.php +++ b/app/Queries/ConversationsQuery.php @@ -26,6 +26,7 @@ * last_message: Message|null, * last_message_at: string|null, * ahead: int, + * last_incoming_message_id: int|null, * partner_read_through_id: int, * partner_read_at: string|null, * is_archived: bool, @@ -96,6 +97,15 @@ public function get(User $user, InboxTab $tab = InboxTab::Open, string $term = ' ->whereNull('messages.event'), 'ahead', ) + ->selectSub( + Message::query() + ->selectRaw('max(messages.id)') + ->whereColumn('messages.conversation_id', 'conversations.id') + ->whereColumn('messages.id', '>', 'mine.visible_from_id') + ->where('messages.user_id', '!=', $user->id) + ->whereNull('messages.event'), + 'last_incoming_message_id', + ) ->with(['lastMessage.user', 'lastMessage.subject', 'lastMessage.voice', 'partner', 'participants.user']) ->orderByDesc('mine.last_message_at') ->orderByDesc('conversations.id') @@ -111,6 +121,32 @@ public function get(User $user, InboxTab $tab = InboxTab::Open, string $term = ' ]; } + /** + * Initial notification baselines across open, stashed and fetched conversations, regardless of read position. + * + * @return array + */ + public function notificationBaseline(User $user): array + { + return Conversation::query() + ->join('conversation_participants as mine', fn (JoinClause $join): JoinClause => $join + ->on('mine.conversation_id', '=', 'conversations.id') + ->where('mine.user_id', $user->id)) + ->leftJoin('conversation_participants as theirs', fn (JoinClause $join): JoinClause => $join + ->on('theirs.conversation_id', '=', 'conversations.id') + ->where('theirs.user_id', '!=', $user->id) + ->where('conversations.kind', ConversationKind::Direct)) + ->whereIn('mine.status', config('remotes.requests') ? [RemoteStatus::Open, RemoteStatus::Fetched] : [RemoteStatus::Open]) + ->where(fn (Builder $query): Builder => $query + ->where('conversations.kind', ConversationKind::Org) + ->orWhereNotIn('theirs.user_id', $user->idsAcrossBlocks())) + ->select('conversations.id') + ->selectRaw('CASE WHEN conversations.last_message_id > mine.visible_from_id THEN conversations.last_message_id ELSE 0 END AS last_message_id') + ->get() + ->mapWithKeys(fn (Conversation $conversation): array => [$conversation->id => (int) $conversation->last_message_id]) + ->all(); + } + /** * Unread messages in open conversations plus fetched requests, for the nav badge. */ @@ -170,6 +206,7 @@ private function summary(User $user, Conversation $conversation): array 'last_message' => $conversation->lastMessage, 'last_message_at' => $conversation->last_message_at?->toJSON(), 'ahead' => $conversation->ahead, + 'last_incoming_message_id' => $conversation->last_incoming_message_id, 'partner_read_through_id' => $sharesReads ? $conversation->partner_read_through_id : 0, 'partner_read_at' => $sharesReads ? $conversation->partner_read_at?->toJSON() : null, 'is_archived' => $conversation->archived_at !== null, diff --git a/public/sounds/remote.wav b/public/sounds/remote.wav new file mode 100644 index 0000000000000000000000000000000000000000..1cdef5d2876a140c3a86790cb88d98705b3547f8 GIT binary patch literal 33558 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 SZ~xu?-=F{I+x~z5^M3$#6g|=a literal 0 HcmV?d00001 diff --git a/public/sw.js b/public/sw.js index 2c0c5e83e..9f4536fc3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -19,6 +19,24 @@ const assetCache = `assets-${version}`; const offlineUrl = '/offline.html'; const offlineFiles = [offlineUrl, '/favicon.ico', '/favicon.svg']; +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = new URL(event.notification.data?.url ?? '/', self.location.origin); + + if (url.origin !== self.location.origin) return; + + event.waitUntil(self.clients.matchAll({ type: 'window' }).then(async (clients) => { + const client = clients[0]; + + if (client) { + await client.navigate(url.href); + await client.focus(); + } else { + await self.clients.openWindow(url.href); + } + })); +}); + self.addEventListener('install', (event) => { event.waitUntil( caches diff --git a/resources/js/components/chat/conversation-actions.tsx b/resources/js/components/chat/conversation-actions.tsx index b76643b4e..01f9ec22b 100644 --- a/resources/js/components/chat/conversation-actions.tsx +++ b/resources/js/components/chat/conversation-actions.tsx @@ -3,6 +3,8 @@ import { Archive, ArchiveRestore, Ban, + Bell, + BellOff, Ellipsis, LogOut, UserRound, @@ -26,15 +28,20 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { useChat } from '@/hooks/use-chat'; +import { useNow } from '@/hooks/use-now'; import { blockPartner, removeOrgMember, setConversationArchived, + setConversationMute, + targetKey, } from '@/lib/chat'; -import type { ChatTarget } from '@/lib/chat'; +import type { ChatTarget, MuteDuration } from '@/lib/chat'; +import { isRemoteMuted } from '@/lib/remote-notifications'; import { cn } from '@/lib/utils'; import { show } from '@/routes/user'; -import type { User } from '@/types'; +import type { ConversationSummary, User } from '@/types'; /** * Stash (archive), pop (unarchive), and block or leave, from the conversation's own header or from its row in the @@ -45,6 +52,7 @@ export function ConversationActions({ title, user, isArchived, + conversation, onLeave, onBlocked, onOpenChange, @@ -57,6 +65,7 @@ export function ConversationActions({ title: string; user: User; isArchived: boolean; + conversation?: ConversationSummary; /** * Called once the conversation leaves the list being shown, so an open thread can step back to the inbox. */ @@ -72,11 +81,38 @@ export function ConversationActions({ const [isMenuOpen, setIsMenuOpen] = useState(false); const [isConfirmingBlock, setIsConfirmingBlock] = useState(false); const [isBlocking, setIsBlocking] = useState(false); + const [isChoosingMute, setIsChoosingMute] = useState(false); + const [isSavingMute, setIsSavingMute] = useState(false); + const chat = useChat(); + const now = useNow(); + const key = targetKey(target); + const preference = chat.mutePreferences[key]; + const until = + preference !== undefined + ? preference.until + : (conversation?.muted_until_at ?? null); + const isMuted = isRemoteMuted(until, now.getTime()); - const changeOpen = (menu: boolean, dialog: boolean) => { + const changeOpen = (menu: boolean, dialog: boolean, mute = false) => { setIsMenuOpen(menu); setIsConfirmingBlock(dialog); - onOpenChange?.(menu || dialog); + setIsChoosingMute(mute); + onOpenChange?.(menu || dialog || mute); + }; + + const mute = async (duration: MuteDuration) => { + setIsSavingMute(true); + try { + await setConversationMute(target, duration); + changeOpen(false, false); + toast.success( + duration === 'off' ? `Unmuted ${title}.` : `Muted ${title}.`, + ); + } catch { + toast.error('Could not update the mute. Try again.'); + } finally { + setIsSavingMute(false); + } }; const archive = async () => { @@ -135,7 +171,9 @@ export function ConversationActions({ changeOpen(open, isConfirmingBlock)} + onOpenChange={(open) => + changeOpen(open, isConfirmingBlock, isChoosingMute) + } > + ))} + + + + changeOpen(false, open)} diff --git a/resources/js/components/chat/conversation-list.tsx b/resources/js/components/chat/conversation-list.tsx index de4d68f60..5f77a41f2 100644 --- a/resources/js/components/chat/conversation-list.tsx +++ b/resources/js/components/chat/conversation-list.tsx @@ -264,6 +264,7 @@ function ConversationRow({ title={title} user={user} isArchived={conversation.is_archived} + conversation={conversation} onLeave={isActive ? onLeaveActive : undefined} onBlocked={isActive ? onLeaveActive : onBlocked} onOpenChange={setIsMenuOpen} diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 4dbd9c6f0..0d44e0c7c 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -286,9 +286,20 @@ export function ConversationThread({ ); useEffect(() => { - if (isVisible && thread?.isLoaded && isPinned.current) { - markThreadRead(target); - } + const markRead = () => { + if (isVisible && thread?.isLoaded && isPinned.current) { + markThreadRead(target); + } + }; + + markRead(); + window.addEventListener('focus', markRead); + document.addEventListener('visibilitychange', markRead); + + return () => { + window.removeEventListener('focus', markRead); + document.removeEventListener('visibilitychange', markRead); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible, latestId, thread?.isLoaded, targetKey(target)]); diff --git a/resources/js/components/remotes-setting.tsx b/resources/js/components/remotes-setting.tsx index 889f8407c..ec10e686d 100644 --- a/resources/js/components/remotes-setting.tsx +++ b/resources/js/components/remotes-setting.tsx @@ -2,6 +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, +} from '@/hooks/use-chat'; +import type { RemoteAlertChannel } from '@/lib/remote-notifications'; +import { setRemoteAlerts } from '@/lib/remote-notifications'; import { cn } from '@/lib/utils'; /** @@ -11,6 +18,30 @@ export function RemotesSetting() { const { auth } = usePage().props; const enabled = auth.remotes !== null; const [processing, setProcessing] = useState(false); + const alertsEnabled = useRemoteAlertsEnabled(auth.user?.id ?? null); + const soundEnabled = useRemoteAlertsEnabled(auth.user?.id ?? null, 'sound'); + const hasPermission = useRemoteNotificationPermission(); + + const saveAlerts = ( + enabled: boolean, + channel: RemoteAlertChannel = 'notifications', + ) => { + if (auth.user === null) return; + void setRemoteAlerts(auth.user.id, enabled, channel) + .then((saved) => { + if (!saved) + toast.error( + channel === 'sound' + ? 'Could not save notification sound. Check your browser’s storage settings.' + : 'Could not save notifications. Check your browser’s site permissions and storage settings.', + ); + }) + .catch(() => + toast.error( + 'Could not enable notifications. Check your browser’s site settings.', + ), + ); + }; const toggle = () => { if (processing) { @@ -76,6 +107,88 @@ export function RemotesSetting() { /> +
+
+

+ Notifications +

+

+ On this browser, while the site is open. No message + previews. +

+ +
+ +
+
+
+

+ Notification sound +

+

+ Play a sound for new messages. +

+
+ +
); } diff --git a/resources/js/hooks/use-chat.ts b/resources/js/hooks/use-chat.ts index 76370bfc9..584a5adf2 100644 --- a/resources/js/hooks/use-chat.ts +++ b/resources/js/hooks/use-chat.ts @@ -5,9 +5,47 @@ import { subscribeToChat, targetKey, watchInbox, + watchRemoteAlerts, watchThread, } from '@/lib/chat'; import type { ChatState, ChatTarget, ThreadState } from '@/lib/chat'; +import type { RemoteAlertChannel } from '@/lib/remote-notifications'; +import { + remoteAlertsEnabled, + remoteNotificationsAllowed, + subscribeRemoteAlerts, +} from '@/lib/remote-notifications'; + +export function useRemoteAlertsEnabled( + userId: string | null, + channel: RemoteAlertChannel = 'notifications', +): boolean { + return useSyncExternalStore( + subscribeRemoteAlerts, + () => userId !== null && remoteAlertsEnabled(userId, channel), + () => false, + ); +} + +export function useRemoteNotificationPermission(): boolean { + return useSyncExternalStore( + subscribeRemoteAlerts, + remoteNotificationsAllowed, + () => false, + ); +} + +export function useRemoteAlerts(userId: string | null): void { + const notifications = useRemoteAlertsEnabled(userId); + const sound = useRemoteAlertsEnabled(userId, 'sound'); + const permitted = useRemoteNotificationPermission(); + const enabled = (notifications && permitted) || sound; + useEffect( + () => + enabled && userId !== null ? watchRemoteAlerts(userId) : undefined, + [enabled, userId], + ); +} export function useChat(): ChatState { return useSyncExternalStore(subscribeToChat, chatSnapshot, chatSnapshot); diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 6999bab7c..583fd706b 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -19,6 +19,7 @@ import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog' import { LocalSignInDialog } from '@/components/local-sign-in-dialog'; import { ProductHuntBanner } from '@/components/product-hunt-banner'; import { SearchDialog } from '@/components/search-dialog'; +import { useRemoteAlerts } from '@/hooks/use-chat'; import { useIsComposerInView } from '@/hooks/use-composer-visibility'; import { usePageDeletion } from '@/hooks/use-deleted-resources'; import { useFixedPage } from '@/hooks/use-fixed-page'; @@ -67,6 +68,7 @@ export default function AppLayout({ children }: AppLayoutProps) { ); useTabSlide(); useFixedPage(isMessages); + useRemoteAlerts(auth.remotes !== null ? (auth.user?.id ?? null) : null); return ( <> diff --git a/resources/js/lib/chat.ts b/resources/js/lib/chat.ts index e1dfc9efe..4686a65fd 100644 --- a/resources/js/lib/chat.ts +++ b/resources/js/lib/chat.ts @@ -1,9 +1,11 @@ import { router } from '@inertiajs/react'; import BannedController from '@/actions/App/Http/Controllers/BannedController'; import ConversationArchiveController from '@/actions/App/Http/Controllers/ConversationArchiveController'; +import ConversationController from '@/actions/App/Http/Controllers/ConversationController'; import ConversationDropController from '@/actions/App/Http/Controllers/ConversationDropController'; import ConversationMergeController from '@/actions/App/Http/Controllers/ConversationMergeController'; import ConversationMessageController from '@/actions/App/Http/Controllers/ConversationMessageController'; +import ConversationMuteController from '@/actions/App/Http/Controllers/ConversationMuteController'; import ConversationReadController from '@/actions/App/Http/Controllers/ConversationReadController'; import ConversationTypingController from '@/actions/App/Http/Controllers/ConversationTypingController'; import InboxController from '@/actions/App/Http/Controllers/InboxController'; @@ -12,6 +14,7 @@ import OrgArchiveController from '@/actions/App/Http/Controllers/OrgArchiveContr import OrgController from '@/actions/App/Http/Controllers/OrgController'; import OrgMemberController from '@/actions/App/Http/Controllers/OrgMemberController'; import OrgMessageController from '@/actions/App/Http/Controllers/OrgMessageController'; +import OrgMuteController from '@/actions/App/Http/Controllers/OrgMuteController'; import OrgReadController from '@/actions/App/Http/Controllers/OrgReadController'; import OrgTypingController from '@/actions/App/Http/Controllers/OrgTypingController'; import RemoteCandidateController from '@/actions/App/Http/Controllers/RemoteCandidateController'; @@ -19,6 +22,7 @@ import UserBlockController from '@/actions/App/Http/Controllers/UserBlockControl import { showTimeout, timeoutFrom } from '@/components/timeout-dialog'; import type { ReactionUsage } from '@/lib/reactions'; import { withReactionUse } from '@/lib/reactions'; +import { createRemoteAlerts, isRemoteMuted } from '@/lib/remote-notifications'; import type { VoiceDraft } from '@/lib/voice-notes'; import type { ConversationSummary, @@ -81,6 +85,7 @@ export type ThreadState = { typing: string[]; role: OrgRole | null; is_archived: boolean; + muted_until_at: string | null; access: PushAccess; pending: PendingMessage[]; /** @@ -114,6 +119,7 @@ export type ChatState = { requests: number | null; isPollingInbox: boolean; threads: Record; + mutePreferences: Record; /** * The emoji the user reacts with, most used first, loaded once something shows a conversation. */ @@ -145,6 +151,7 @@ let state: ChatState = { requests: null, isPollingInbox: false, threads: {}, + mutePreferences: {}, reactionUsage: null, }; const listeners = new Set<() => void>(); @@ -156,6 +163,10 @@ const reactionsSettledAt = new Map(); let inboxWatchers = 0; let inboxTimer: number | undefined; let isLoadingReactionUsage = false; +let remoteAlerts: ReturnType | null = null; +let isFetchingInbox = false; +let muteVersion = 0; +let remotesPageWatchers = 0; export function targetKey(target: ChatTarget): string { return target.kind === 'direct' @@ -180,6 +191,7 @@ function urls(target: ChatTarget) { const partner = { partner: target.partner.username }; return { + show: ConversationController.show.url(partner), index: (query?: Record) => ConversationMessageController.index.url( partner, @@ -190,12 +202,14 @@ function urls(target: ChatTarget) { typing: ConversationTypingController.update.url(partner), stash: ConversationArchiveController.store.url(partner), pop: ConversationArchiveController.destroy.url(partner), + mute: ConversationMuteController.update.url(partner), }; } const org = { org: target.org.id }; return { + show: OrgController.show.url(org), index: (query?: Record) => OrgMessageController.index.url(org, query ? { query } : undefined), store: OrgMessageController.store.url(org), @@ -203,6 +217,7 @@ function urls(target: ChatTarget) { typing: OrgTypingController.update.url(org), stash: OrgArchiveController.store.url(org), pop: OrgArchiveController.destroy.url(org), + mute: OrgMuteController.update.url(org), }; } @@ -229,6 +244,22 @@ function updateDuringRender(next: Partial): void { queueMicrotask(() => listeners.forEach((listener) => listener())); } +/** + * Inbox, search, and thread responses share mute state. A slower, older request must not undo a newer read or save. + */ +function withMutePreferences( + preferences: [string, string | null][], + version = ++muteVersion, +): ChatState['mutePreferences'] { + const next = { ...state.mutePreferences }; + for (const [key, until] of preferences) { + if (version >= (next[key]?.version ?? 0)) { + next[key] = { until, version }; + } + } + return next; +} + function updateThread( key: string, change: (thread: ThreadState) => Partial, @@ -382,6 +413,12 @@ function byLatest(conversations: ConversationSummary[]): ConversationSummary[] { export function seedInbox(inbox: InboxPage): void { updateDuringRender({ conversations: inbox.conversations, + mutePreferences: withMutePreferences( + inbox.conversations.map((item) => [ + summaryKey(item), + item.muted_until_at, + ]), + ), hasMore: { ...state.hasMore, open: inbox.has_more }, unread: state.unread ?? unreadOf(inbox.conversations), }); @@ -410,12 +447,40 @@ function withFirstPage( async function fetchInbox( tab: InboxTab, before?: string, -): Promise { - return request( + notificationBaseline = false, +): Promise< + InboxPage & { + unread: number; + requests: number; + notification_baseline?: Record; + } +> { + const version = ++muteVersion; + const page = await request< + InboxPage & { + unread: number; + requests: number; + notification_baseline?: Record; + } + >( InboxController.index.url({ - query: { tab, ...(before === undefined ? {} : { before }) }, + query: { + tab, + ...(before === undefined ? {} : { before }), + ...(notificationBaseline ? { notification_baseline: 1 } : {}), + }, }), ); + update({ + mutePreferences: withMutePreferences( + page.conversations.map((item) => [ + summaryKey(item), + item.muted_until_at, + ]), + version, + ), + }); + return page; } /** @@ -479,6 +544,7 @@ function emptyThread(target: ChatTarget): ThreadState { typing: [], role: null, is_archived: false, + muted_until_at: null, access: 'open', pending: [], reactions_version: null, @@ -497,6 +563,7 @@ function fromServer(thread: Thread | OrgThread): Partial { typing: thread.typing, role: thread.role, is_archived: thread.is_archived, + muted_until_at: thread.muted_until_at, access: thread.access, } : { @@ -504,6 +571,7 @@ function fromServer(thread: Thread | OrgThread): Partial { partner_read_at: thread.partner_read_at, partner_typing: thread.partner_typing, is_archived: thread.is_archived, + muted_until_at: thread.muted_until_at, access: thread.access, }; } @@ -519,6 +587,7 @@ export function seedThread( const current = state.threads[key] ?? emptyThread(target); updateDuringRender({ + mutePreferences: withMutePreferences([[key, thread.muted_until_at]]), threads: { ...state.threads, [key]: { @@ -554,23 +623,48 @@ export function rememberTarget(target: ChatTarget): void { } async function pollInbox(): Promise { - if (document.hidden) { + if ((document.hidden && remoteAlerts === null) || isFetchingInbox) { return; } + isFetchingInbox = true; + try { - const { conversations, unread, requests } = await fetchInbox('open'); + const alerts = remoteAlerts; + const { conversations, unread, requests, notification_baseline } = + await fetchInbox('open', undefined, alerts?.needsBaseline()); + const isReading = (conversation: ConversationSummary) => + !document.hidden && + document.hasFocus() && + isWatching(summaryKey(conversation)); + if ( + alerts === remoteAlerts && + (!alerts?.needsBaseline() || notification_baseline !== undefined) + ) { + void alerts?.receive( + conversations.map((conversation) => ({ + ...conversation, + muted_until_at: + state.mutePreferences[summaryKey(conversation)].until, + })), + (conversation) => + isViewingRemotes() || + isReading(conversation) || + isRemoteMuted( + state.mutePreferences[summaryKey(conversation)].until, + ), + notification_baseline, + ); + } const requestsChanged = state.requests !== null && requests !== state.requests; - const watched = conversations.filter((conversation) => - isWatching(summaryKey(conversation)), - ); + const watched = conversations.filter(isReading); update({ conversations: withFirstPage( state.conversations, conversations.map((conversation) => - isWatching(summaryKey(conversation)) + isReading(conversation) ? { ...conversation, ahead: 0 } : conversation, ), @@ -589,9 +683,48 @@ async function pollInbox(): Promise { } } catch { // Keep the last known inbox until the next poll. + } finally { + isFetchingInbox = false; } } +export function watchRemoteAlerts(userId: string): () => void { + const alerts = createRemoteAlerts( + userId, + (conversation) => urls(targetOf(conversation)).show, + () => + isViewingRemotes() + ? ['*'] + : !document.hidden && document.hasFocus() + ? [...threadWatchers.keys()].map( + (key) => urls(state.threads[key].target).show, + ) + : [], + ); + remoteAlerts = alerts; + const unwatch = watchInbox(); + + return () => { + alerts.stop(); + if (remoteAlerts === alerts) remoteAlerts = null; + unwatch(); + }; +} + +function isViewingRemotes(): boolean { + return remotesPageWatchers > 0 && !document.hidden && document.hasFocus(); +} + +export function watchRemotesPage(): () => void { + remotesPageWatchers++; + remoteAlerts?.updateReading(); + + return () => { + remotesPageWatchers--; + remoteAlerts?.updateReading(); + }; +} + function scheduleInbox(): void { window.clearTimeout(inboxTimer); @@ -647,6 +780,7 @@ async function pollThread(target: ChatTarget): Promise { const reactionsAfter = after === null ? null : (thread?.reactions_version ?? null); const polledAt = performance.now(); + const version = ++muteVersion; const fresh = await request< (Thread & { partner: User }) | (OrgThread & { org: OrgRef }) @@ -672,6 +806,10 @@ async function pollThread(target: ChatTarget): Promise { } update({ + mutePreferences: withMutePreferences( + [[key, fresh.muted_until_at]], + version, + ), threads: { ...state.threads, [key]: { @@ -747,6 +885,8 @@ export function watchThread(target: ChatTarget): () => void { })); } + remoteAlerts?.updateReading(); + const tick = async () => { window.clearTimeout(timer); @@ -795,6 +935,7 @@ export function watchThread(target: ChatTarget): () => void { threadWatchers.set(key, watchers); } + remoteAlerts?.updateReading(); scheduleInbox(); }; } @@ -819,11 +960,13 @@ function touchInbox(target: ChatTarget, message: Message): void { partner: target.kind === 'direct' ? target.partner : null, members: existing?.members ?? null, last_message: message, + last_incoming_message_id: existing?.last_incoming_message_id ?? null, last_message_at: message.created_at, ahead: existing?.ahead ?? 0, partner_read_through_id: existing?.partner_read_through_id ?? 0, partner_read_at: existing?.partner_read_at ?? null, is_archived: false, + muted_until_at: existing?.muted_until_at ?? null, }; update({ @@ -1081,6 +1224,8 @@ export function retryMessage(target: ChatTarget, clientId: string): void { } export function markThreadRead(target: ChatTarget): void { + if (document.hidden || !document.hasFocus()) return; + const key = targetKey(target); const thread = state.threads[key]; const latest = thread?.messages.at(-1)?.id ?? 0; @@ -1156,6 +1301,7 @@ export async function searchConversations( tab: InboxTab, signal: AbortSignal, ): Promise { + const version = ++muteVersion; const response = await fetch( InboxController.index.url({ query: { q: term, tab } }), { headers: { Accept: 'application/json' }, cache: 'no-store', signal }, @@ -1165,7 +1311,17 @@ export async function searchConversations( throw new Error(`Conversation search failed: ${response.status}`); } - return ((await response.json()) as InboxPage).conversations; + const { conversations } = (await response.json()) as InboxPage; + update({ + mutePreferences: withMutePreferences( + conversations.map((item) => [ + summaryKey(item), + item.muted_until_at, + ]), + version, + ), + }); + return conversations; } /** @@ -1296,6 +1452,34 @@ export async function setConversationArchived( } } +export type MuteDuration = 'hour' | 'tomorrow' | 'week' | 'forever' | 'off'; + +export async function setConversationMute( + target: ChatTarget, + duration: MuteDuration, +): Promise { + const preference = await request<{ muted_until_at: string | null }>( + urls(target).mute, + 'PUT', + { duration }, + ); + const key = targetKey(target); + const apply = (items: ConversationSummary[] | null) => + items?.map((item) => + summaryKey(item) === key ? { ...item, ...preference } : item, + ) ?? null; + + update({ + mutePreferences: withMutePreferences([ + [key, preference.muted_until_at], + ]), + conversations: apply(state.conversations), + fetched: apply(state.fetched), + archived: apply(state.archived), + }); + updateThread(key, () => preference); +} + /** * Blocks through the same endpoint as profiles. Its redirect back to the conversation is not followed, since * that conversation no longer exists for either of them. diff --git a/resources/js/lib/remote-notifications.ts b/resources/js/lib/remote-notifications.ts new file mode 100644 index 000000000..070b017a9 --- /dev/null +++ b/resources/js/lib/remote-notifications.ts @@ -0,0 +1,302 @@ +import { readStorage, writeStorage } from '@/lib/storage'; +import type { ConversationSummary } from '@/types'; + +export type RemoteAlertChannel = 'notifications' | 'sound'; + +const listeners = new Set<() => void>(); +const preferenceKey = (userId: string, channel: RemoteAlertChannel) => + `remote-alerts:${userId}:${channel}`; + +export function remoteAlertsEnabled( + userId: string, + channel: RemoteAlertChannel = 'notifications', +): boolean { + return ( + (readStorage(preferenceKey(userId, channel)) || + readStorage(`remote-alerts:${userId}`)) !== 'off' + ); +} + +export function remoteNotificationsAllowed(): boolean { + return ( + 'Notification' in window && + window.isSecureContext && + Notification.permission === 'granted' + ); +} + +export function subscribeRemoteAlerts(listener: () => void): () => void { + let active = true; + let permission: PermissionStatus | undefined; + listeners.add(listener); + window.addEventListener('storage', listener); + window.addEventListener('focus', listener); + document.addEventListener('visibilitychange', listener); + void navigator.permissions + ?.query({ name: 'notifications' }) + .then((status) => { + if (!active) return; + permission = status; + status.addEventListener('change', listener); + listener(); + }) + .catch(() => undefined); + + return () => { + active = false; + listeners.delete(listener); + window.removeEventListener('storage', listener); + window.removeEventListener('focus', listener); + document.removeEventListener('visibilitychange', listener); + permission?.removeEventListener('change', listener); + }; +} + +export async function setRemoteAlerts( + userId: string, + enabled: boolean, + channel: RemoteAlertChannel = 'notifications', +): Promise { + if (enabled && channel === 'notifications') { + if (!('Notification' in window) || !window.isSecureContext) { + return false; + } + + if ((await Notification.requestPermission()) !== 'granted') { + return false; + } + } + + writeStorage(preferenceKey(userId, channel), enabled ? 'on' : 'off'); + listeners.forEach((listener) => listener()); + + return ( + readStorage(preferenceKey(userId, channel)) === (enabled ? 'on' : 'off') + ); +} + +export function isRemoteMuted(until: string | null, now = Date.now()): boolean { + return until !== null && Date.parse(until) > now; +} + +/** + * Only new arrivals, never the backlog on opening the site. One shared cooldown covers every remote and tab. + * No message text leaves the page for the operating system's notification history. + */ +export function createRemoteAlerts( + userId: string, + urlOf: (conversation: ConversationSummary) => string, + reading: () => string[], +) { + let latest: Record | null = null; + let stopped = false; + let lastAlertAt = 0; + const sound = new Audio('/sounds/remote.wav'); + const notifications = new Set(); + const cooldownKey = `remote-alert-cooldown:${userId}`; + const readingKey = `remote-alert-reading:${userId}`; + const tabId = crypto.randomUUID(); + + const releaseReading = () => { + if (readStorage(readingKey).split('\n')[0] === tabId) { + writeStorage(readingKey, ''); + } + }; + + const updateReading = () => { + const urls = reading(); + + if (urls.length === 0) { + releaseReading(); + } else { + writeStorage( + readingKey, + [tabId, Date.now() + 15_000, ...urls].join('\n'), + ); + } + }; + + updateReading(); + const readingTimer = window.setInterval(updateReading, 5_000); + window.addEventListener('focus', updateReading); + window.addEventListener('blur', updateReading); + window.addEventListener('pageshow', updateReading); + window.addEventListener('pagehide', releaseReading); + document.addEventListener('visibilitychange', updateReading); + + const unlock = () => { + sound.muted = true; + void sound + .play() + .then(() => { + sound.pause(); + sound.currentTime = 0; + sound.muted = false; + window.removeEventListener('pointerdown', unlock); + window.removeEventListener('keydown', unlock); + }) + .catch(() => { + sound.muted = false; + }); + }; + + window.addEventListener('pointerdown', unlock); + window.addEventListener('keydown', unlock); + + return { + updateReading, + needsBaseline: () => latest === null, + async receive( + conversations: ConversationSummary[], + isSuppressed: (conversation: ConversationSummary) => boolean, + initialBaseline: Record = {}, + ): Promise { + const initializing = latest === null; + const previous = latest ?? initialBaseline; + const arrivals = conversations.filter( + (item) => + (item.last_incoming_message_id ?? 0) > + (previous[item.id] ?? 0) && + item.ahead > 0 && + !isRemoteMuted(item.muted_until_at), + ); + latest = { ...previous }; + for (const item of conversations) { + latest[item.id] = Math.max( + latest[item.id] ?? 0, + item.last_incoming_message_id ?? 0, + ); + } + + if (initializing || stopped || arrivals.length === 0) { + return; + } + + const notify = async () => { + if (stopped) return; + + const now = Date.now(); + const [storedAt, ...storedArrivals] = + readStorage(cooldownKey).split('\n'); + const consumed = new Map( + storedArrivals.map((entry) => { + const [id, message] = entry.split(':'); + return [id, Number(message) || 0]; + }), + ); + const [, readingUntil, ...readingUrls] = + readStorage(readingKey).split('\n'); + const incoming = arrivals.find( + (item) => + (item.last_incoming_message_id ?? 0) > + (consumed.get(item.id) ?? 0) && + !isSuppressed(item) && + !( + Number(readingUntil) > now && + (readingUrls.includes('*') || + readingUrls.includes(urlOf(item))) + ), + ); + for (const item of arrivals) { + consumed.set( + item.id, + Math.max( + consumed.get(item.id) ?? 0, + item.last_incoming_message_id ?? 0, + ), + ); + } + const consumedState = [...consumed] + .map(([id, message]) => `${id}:${message}`) + .join('\n'); + const previousAlertAt = Math.max( + lastAlertAt, + Number(storedAt) || 0, + ); + + writeStorage( + cooldownKey, + `${previousAlertAt}\n${consumedState}`, + ); + const showBanner = + remoteAlertsEnabled(userId) && remoteNotificationsAllowed(); + const playSound = remoteAlertsEnabled(userId, 'sound'); + + if ( + incoming === undefined || + !(showBanner || playSound) || + now - previousAlertAt < 10_000 + ) { + return; + } + + lastAlertAt = now; + writeStorage(cooldownKey, `${now}\n${consumedState}`); + + if (playSound) { + sound.currentTime = 0; + void sound.play().catch(() => undefined); + } + + if (!showBanner) return; + + const options: NotificationOptions = { + body: 'You have a new message in Remotes.', + tag: 'devnull-remotes', + silent: true, + data: { url: urlOf(incoming) }, + }; + + try { + const notification = new Notification( + '/dev/null · Remotes', + options, + ); + notifications.add(notification); + notification.onclose = () => + notifications.delete(notification); + notification.onclick = () => { + notification.close(); + if (!stopped) { + window.focus(); + window.location.assign(urlOf(incoming)); + } + }; + } catch { + // Some mobile browsers only support notifications through the installed service worker. + const registration = + await navigator.serviceWorker?.getRegistration(); + if (!registration || stopped) return; + await registration.showNotification( + '/dev/null · Remotes', + options, + ); + } + }; + + try { + if (navigator.locks) { + await navigator.locks.request(cooldownKey, notify); + } else { + await notify(); + } + } catch { + // Permission, audio playback, or storage may be restricted; messages still arrive normally. + } + }, + stop() { + stopped = true; + sound.pause(); + notifications.forEach((notification) => notification.close()); + releaseReading(); + window.clearInterval(readingTimer); + window.removeEventListener('focus', updateReading); + window.removeEventListener('blur', updateReading); + window.removeEventListener('pageshow', updateReading); + window.removeEventListener('pagehide', releaseReading); + document.removeEventListener('visibilitychange', updateReading); + window.removeEventListener('pointerdown', unlock); + window.removeEventListener('keydown', unlock); + }, + }; +} diff --git a/resources/js/pages/conversation/index.tsx b/resources/js/pages/conversation/index.tsx index 83f7a2425..70cdb97b3 100644 --- a/resources/js/pages/conversation/index.tsx +++ b/resources/js/pages/conversation/index.tsx @@ -5,7 +5,7 @@ import { SquarePen, UsersRound, } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import ConversationController from '@/actions/App/Http/Controllers/ConversationController'; import OrgController from '@/actions/App/Http/Controllers/OrgController'; import { ConversationActions } from '@/components/chat/conversation-actions'; @@ -24,7 +24,7 @@ import { UserAvatar } from '@/components/user-avatar'; import { useChat, useInbox } from '@/hooks/use-chat'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; -import { seedInbox, seedThread, targetKey } from '@/lib/chat'; +import { seedInbox, seedThread, targetKey, watchRemotesPage } from '@/lib/chat'; import type { ChatTarget } from '@/lib/chat'; import { setChatDocked, useChatDock } from '@/lib/chat-dock'; import { cn, laneColor } from '@/lib/utils'; @@ -64,6 +64,7 @@ export default function Index({ } useInbox(); + useEffect(watchRemotesPage, []); // Keeps the inbox's tab and search while moving between conversations. const visit = (href: Parameters[0]) => diff --git a/resources/js/types/conversation.ts b/resources/js/types/conversation.ts index de637af06..81d9e8f18 100644 --- a/resources/js/types/conversation.ts +++ b/resources/js/types/conversation.ts @@ -97,11 +97,13 @@ export type ConversationSummary = { partner: User | null; members: { user: User; role: OrgRole | null }[] | null; last_message: Message | null; + last_incoming_message_id: number | null; last_message_at: string | null; ahead: number; partner_read_through_id: number; partner_read_at: string | null; is_archived: boolean; + muted_until_at: string | null; }; export type Thread = { @@ -114,6 +116,7 @@ export type Thread = { partner_read_at: string | null; partner_typing: boolean; is_archived: boolean; + muted_until_at: string | null; access: PushAccess; }; @@ -126,6 +129,7 @@ export type OrgThread = { members: OrgMember[]; typing: string[]; is_archived: boolean; + muted_until_at: string | null; role: OrgRole | null; access: PushAccess; }; diff --git a/tests/Feature/Controllers/Api/ConversationControllerTest.php b/tests/Feature/Controllers/Api/ConversationControllerTest.php index 7fecc9a38..b43fbd750 100644 --- a/tests/Feature/Controllers/Api/ConversationControllerTest.php +++ b/tests/Feature/Controllers/Api/ConversationControllerTest.php @@ -35,10 +35,12 @@ ->assertJsonPath('data.0.last_message.body', 'ship it') ->assertJsonPath('data.0.last_message.user_id', $nuno->id) ->assertJsonPath('data.0.ahead', 2) + ->assertJsonPath('data.0.last_incoming_message_id', $latest->id) ->assertJsonPath('data.0.is_archived', false) ->assertJsonPath('data.1.partner.username', 'taylorotwell') ->assertJsonPath('data.1.last_message.id', $thanks->id) ->assertJsonPath('data.1.ahead', 0) + ->assertJsonPath('data.1.last_incoming_message_id', null) ->assertJsonPath('has_more', false) ->assertJsonPath('unread_count', 2) ->assertJsonPath('requests_count', 0); @@ -73,6 +75,46 @@ ->assertJsonPath('data.0.last_message.event', 'created'); }); +it('points at the newest incoming org message even when an event follows it', function (): void { + $mark = User::factory()->withRemotes()->create(); + [$taylor] = followersOf($mark, 1); + $this->user->following()->attach($mark); + $org = createOrg($mark, [$this->user]); + $shipIt = pushToOrg($mark, $org, 'ship it'); + $this->actingAs($mark)->postJson(route('org-member.store', $org), ['members' => [$taylor->username], 'share_history' => true])->assertNoContent(); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation.index')) + ->assertOk() + ->assertJsonPath('data.0.last_message.event', 'added') + ->assertJsonPath('data.0.ahead', 1) + ->assertJsonPath('data.0.last_incoming_message_id', $shipIt->id) + ->assertJsonPath('unread_count', 1); +}); + +it('only exposes incoming org message ids from the history shared with a new member', function (bool $sharesHistory): void { + $mark = User::factory()->withRemotes()->create(); + [$nuno] = followersOf($mark, 1); + $this->user->following()->attach($mark); + $org = createOrg($mark, [$nuno]); + $beforeJoining = pushToOrg($mark, $org, 'before joining'); + + $this->actingAs($mark)->postJson(route('org-member.store', $org), [ + 'members' => [$this->user->username], + 'share_history' => $sharesHistory, + ])->assertNoContent(); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation.index')) + ->assertOk() + ->assertJsonPath('data.0.id', $org) + ->assertJsonPath('data.0.last_incoming_message_id', $sharesHistory ? $beforeJoining->id : null); + + $afterJoining = pushToOrg($mark, $org, 'after joining'); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation.index')) + ->assertOk() + ->assertJsonPath('data.0.last_incoming_message_id', $afterJoining->id); +})->with([false, true]); + it('lists the stashed and fetched tabs', function (): void { $nuno = User::factory()->withRemotes()->create(); $stranger = User::factory()->withRemotes()->create(['username' => 'stranger']); diff --git a/tests/Feature/Controllers/InboxControllerTest.php b/tests/Feature/Controllers/InboxControllerTest.php index 1bab70e4c..4617c84f7 100644 --- a/tests/Feature/Controllers/InboxControllerTest.php +++ b/tests/Feature/Controllers/InboxControllerTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use App\Enums\RemoteStatus; +use App\Models\Conversation; +use App\Models\ConversationParticipant; use App\Models\User; use App\Queries\ConversationsQuery; @@ -9,27 +12,33 @@ config(['remotes.requests' => true, 'remotes.everyone' => true]); }); -it('lists conversations by latest message with what is unread', function (): void { +it('lists conversations by latest message with what is unread', function (array $query): void { $mark = User::factory()->withRemotes()->create(); $nuno = User::factory()->withRemotes()->create(['username' => 'nunomaduro']); $taylor = User::factory()->withRemotes()->create(['username' => 'taylorotwell']); sendChatMessage($mark, $taylor, 'thanks!'); sendChatMessage($nuno, $mark, 'did you see the PR?'); - sendChatMessage($nuno, $mark, 'ship it'); + $latest = sendChatMessage($nuno, $mark, 'ship it'); - $response = $this->actingAs($mark)->getJson(route('inbox.index')) + $response = $this->actingAs($mark)->getJson(route('inbox.index', $query)) ->assertOk() ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonMissingPath('notification_baseline') ->assertJsonPath('unread', 2); expect($response->json('conversations'))->toHaveCount(2) ->and($response->json('conversations.0.partner.username'))->toBe('nunomaduro') ->and($response->json('conversations.0.ahead'))->toBe(2) ->and($response->json('conversations.0.last_message.body'))->toBe('ship it') + ->and($response->json('conversations.0.last_incoming_message_id'))->toBe($latest->id) ->and($response->json('conversations.1.partner.username'))->toBe('taylorotwell') ->and($response->json('conversations.1.ahead'))->toBe(0) - ->and($response->json('conversations.1.last_message.user_id'))->toBe($mark->id); -}); + ->and($response->json('conversations.1.last_message.user_id'))->toBe($mark->id) + ->and($response->json('conversations.1.last_incoming_message_id'))->toBeNull(); +})->with([ + 'not requested' => [[]], + 'explicitly disabled' => [['notification_baseline' => 0]], +]); it('clears what is unread once read', function (): void { $mark = User::factory()->withRemotes()->create(); @@ -38,16 +47,203 @@ $this->actingAs($mark)->putJson(route('conversation-read.update', $nuno), ['through' => $message->id]); - $this->getJson(route('inbox.index'))->assertJsonPath('unread', 0)->assertJsonPath('conversations.0.ahead', 0); + $this->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertJsonPath('unread', 0) + ->assertJsonPath('notification_baseline', [$message->conversation_id => $message->id]) + ->assertJsonPath('conversations.0.ahead', 0) + ->assertJsonPath('conversations.0.last_incoming_message_id', $message->id); +}); + +it('baselines stashed unread messages without reading them or replaying them after a pop', function (): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + $message = sendChatMessage($nuno, $mark, 'ship it'); + + $this->actingAs($mark)->postJson(route('conversation-archive.store', $nuno))->assertNoContent(); + + $this->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonPath('conversations', []) + ->assertJsonPath('unread', 0) + ->assertJsonPath('notification_baseline', [$message->conversation_id => $message->id]); + $this->getJson(route('inbox.index', ['tab' => 'stashed'])) + ->assertOk() + ->assertJsonPath('conversations.0.ahead', 1) + ->assertJsonMissingPath('notification_baseline'); + + $this->deleteJson(route('conversation-archive.destroy', $nuno))->assertNoContent(); + + $this->getJson(route('inbox.index')) + ->assertOk() + ->assertJsonPath('conversations.0.ahead', 1) + ->assertJsonPath('unread', 1) + ->assertJsonPath('conversations.0.last_incoming_message_id', $message->id) + ->assertJsonMissingPath('notification_baseline'); + $this->assertDatabaseHas('conversation_participants', [ + 'conversation_id' => $message->conversation_id, + 'user_id' => $mark->id, + 'read_through_id' => 0, + 'read_at' => null, + ]); + + $next = sendChatMessage($nuno, $mark, 'one more thing'); + + $this->actingAs($mark)->getJson(route('inbox.index')) + ->assertOk() + ->assertJsonPath('conversations.0.ahead', 2) + ->assertJsonPath('conversations.0.last_incoming_message_id', $next->id) + ->assertJsonMissingPath('notification_baseline'); +}); + +it('baselines fetched but not dropped or other peoples messages', function (RemoteStatus $status): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + $taylor = User::factory()->withRemotes()->create(); + $message = sendChatMessage($nuno, $mark, 'hey'); + ConversationParticipant::query()->where('user_id', $mark->id)->update(['status' => $status]); + sendChatMessage($nuno, $taylor, 'private'); + + $this->actingAs($mark)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonPath('conversations', []) + ->assertJsonPath('notification_baseline', $status === RemoteStatus::Fetched ? [$message->conversation_id => $message->id] : []); + + config(['remotes.requests' => false]); + + $this->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonPath('notification_baseline', []); +})->with([RemoteStatus::Fetched, RemoteStatus::Dropped]); + +it('baselines old stashes and newer conversations independently of search results', function (): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(['username' => 'nunomaduro']); + $taylor = User::factory()->withRemotes()->create(['username' => 'taylorotwell']); + $stashed = sendChatMessage($nuno, $mark, 'old stash'); + $this->actingAs($mark)->postJson(route('conversation-archive.store', $nuno))->assertNoContent(); + $latest = sendChatMessage($taylor, $mark, 'new conversation'); + + $this->actingAs($mark)->getJson(route('inbox.index', ['q' => 'taylor', 'notification_baseline' => 1])) + ->assertOk() + ->assertJsonCount(1, 'conversations') + ->assertJsonPath('conversations.0.id', $latest->conversation_id) + ->assertJsonPath('notification_baseline.'.$stashed->conversation_id, $stashed->id) + ->assertJsonPath('notification_baseline.'.$latest->conversation_id, $latest->id) + ->assertJsonCount(2, 'notification_baseline'); }); -it('leaves out conversations across a block', function (): void { +it('baselines conversations without visible last messages at zero', function (?int $visibilityOffset): void { + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + $message = sendChatMessage($nuno, $mark, 'hidden history'); + + if ($visibilityOffset === null) { + Conversation::query()->whereKey($message->conversation_id)->update(['last_message_id' => null]); + } else { + ConversationParticipant::query()->where('user_id', $mark->id)->update(['visible_from_id' => $message->id + $visibilityOffset]); + } + + $this->actingAs($mark)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonPath('notification_baseline', [$message->conversation_id => 0]); +})->with(['no last message' => [null], 'at visibility boundary' => [0], 'before visibility boundary' => [1]]); + +it("keeps the newest incoming org message past later events and the viewer's own messages", function (): void { + $mark = User::factory()->withRemotes()->create(); + [$nuno, $taylor, $jess] = followersOf($mark, 3); + $org = createOrg($mark, [$nuno, $taylor]); + + $this->actingAs($nuno)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertJsonPath('notification_baseline', [$org => Conversation::query()->findOrFail($org)->last_message_id]) + ->assertJsonPath('conversations.0.last_message.event', 'created') + ->assertJsonPath('conversations.0.last_incoming_message_id', null); + + $shipIt = pushToOrg($mark, $org, 'ship it'); + $this->actingAs($mark)->postJson(route('org-member.store', $org), ['members' => [$jess->username], 'share_history' => true])->assertNoContent(); + + $this->actingAs($nuno)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertJsonPath('unread', 1) + ->assertJsonPath('notification_baseline', [$org => Conversation::query()->findOrFail($org)->last_message_id]) + ->assertJsonPath('conversations.0.last_message.event', 'added') + ->assertJsonPath('conversations.0.ahead', 1) + ->assertJsonPath('conversations.0.last_incoming_message_id', $shipIt->id); + + $this->actingAs($mark)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertJsonPath('unread', 0) + ->assertJsonPath('notification_baseline', [$org => Conversation::query()->findOrFail($org)->last_message_id]) + ->assertJsonPath('conversations.0.ahead', 0) + ->assertJsonPath('conversations.0.last_incoming_message_id', null); + + $this->actingAs($nuno)->putJson(route('org-read.update', $org), ['through' => $shipIt->id])->assertNoContent(); + + $this->getJson(route('inbox.index')) + ->assertJsonPath('unread', 0) + ->assertJsonPath('conversations.0.ahead', 0) + ->assertJsonPath('conversations.0.last_incoming_message_id', $shipIt->id); + + $reply = pushToOrg($nuno, $org, 'on it'); + + $this->actingAs($nuno)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertJsonPath('unread', 0) + ->assertJsonPath('notification_baseline', [$org => $reply->id]) + ->assertJsonPath('conversations.0.last_message.id', $reply->id) + ->assertJsonPath('conversations.0.last_incoming_message_id', $shipIt->id); + + $this->actingAs($mark)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertJsonPath('unread', 1) + ->assertJsonPath('notification_baseline', [$org => $reply->id]) + ->assertJsonPath('conversations.0.ahead', 1) + ->assertJsonPath('conversations.0.last_incoming_message_id', $reply->id); +}); + +it('only exposes incoming org message ids from the history shared with a new member', function (bool $sharesHistory): void { + $mark = User::factory()->withRemotes()->create(); + [$nuno, $jess] = followersOf($mark, 2); + $org = createOrg($mark, [$nuno]); + $beforeJoining = pushToOrg($mark, $org, 'before joining'); + + $this->actingAs($mark)->postJson(route('org-member.store', $org), [ + 'members' => [$jess->username], + 'share_history' => $sharesHistory, + ])->assertNoContent(); + + $this->actingAs($jess)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonPath('notification_baseline', [$org => Conversation::query()->findOrFail($org)->last_message_id]) + ->assertJsonPath('conversations.0.id', $org) + ->assertJsonPath('conversations.0.last_incoming_message_id', $sharesHistory ? $beforeJoining->id : null); + + $afterJoining = pushToOrg($mark, $org, 'after joining'); + + $this->actingAs($jess)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonPath('notification_baseline', [$org => $afterJoining->id]) + ->assertJsonPath('conversations.0.last_incoming_message_id', $afterJoining->id); +})->with([false, true]); + +it('leaves out conversations across a block', function (bool $blockedByPartner): void { $mark = User::factory()->withRemotes()->create(); $nuno = User::factory()->withRemotes()->create(); sendChatMessage($nuno, $mark, 'hey'); + $blockedByPartner ? $nuno->blocking()->attach($mark) : $mark->blocking()->attach($nuno); + + $this->actingAs($mark)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertJsonPath('conversations', []) + ->assertJsonPath('unread', 0) + ->assertJsonPath('notification_baseline', []); +})->with([false, true]); + +it('keeps visible org messages in the notification baseline across a block', function (): void { + $mark = User::factory()->withRemotes()->create(); + [$nuno] = followersOf($mark, 1); + $org = createOrg($mark, [$nuno]); + $message = pushToOrg($nuno, $org, 'org message'); $mark->blocking()->attach($nuno); - $this->actingAs($mark)->getJson(route('inbox.index'))->assertJsonPath('conversations', [])->assertJsonPath('unread', 0); + $this->actingAs($mark)->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonPath('conversations.0.last_incoming_message_id', $message->id) + ->assertJsonPath('notification_baseline', [$message->conversation_id => $message->id]); }); it('requires authentication', function (): void { @@ -61,7 +257,9 @@ sendChatMessage($nuno, $mark, 'ship it'); sendChatMessage($taylor, $mark, 'thanks!'); - $response = $this->actingAs($mark)->getJson(route('inbox.index', ['q' => $term]))->assertOk(); + $response = $this->actingAs($mark)->getJson(route('inbox.index', ['q' => $term])) + ->assertOk() + ->assertJsonMissingPath('notification_baseline'); expect(collect($response->json('conversations'))->pluck('partner.username')->all())->toBe(['nunomaduro']); })->with(['nuno', '@NunoMad', 'maduro']); @@ -72,6 +270,7 @@ })->with([ [fn (): array => ['tab' => 'maybe'], 'tab'], [fn (): array => ['q' => str_repeat('a', 101)], 'q'], + [fn (): array => ['notification_baseline' => 'yes'], 'notification_baseline'], ]); it('pages through conversations, newest first', function (): void { @@ -82,13 +281,21 @@ sendChatMessage($partner, $mark, "message {$index}"); }); - $first = $this->actingAs($mark)->getJson(route('inbox.index'))->assertJsonPath('has_more', true); + $first = $this->actingAs($mark)->getJson(route('inbox.index')) + ->assertJsonPath('has_more', true) + ->assertJsonMissingPath('notification_baseline'); expect($first->json('conversations'))->toHaveCount(ConversationsQuery::PAGE) ->and($first->json('conversations.0.last_message.body'))->toBe('message '.(ConversationsQuery::PAGE + 1)); $second = $this->getJson(route('inbox.index', ['before' => $first->json('conversations.'.(ConversationsQuery::PAGE - 1).'.id')])) - ->assertJsonPath('has_more', false); + ->assertJsonPath('has_more', false) + ->assertJsonMissingPath('notification_baseline'); expect(collect($second->json('conversations'))->pluck('last_message.body')->all())->toBe(['message 1', 'message 0']); + + $this->getJson(route('inbox.index', ['notification_baseline' => 1])) + ->assertOk() + ->assertJsonCount(ConversationsQuery::PAGE + 2, 'notification_baseline') + ->assertJsonPath('notification_baseline.'.$second->json('conversations.1.id'), $second->json('conversations.1.last_message.id')); }); diff --git a/tests/Unit/RemoteNotifications.test.tsx b/tests/Unit/RemoteNotifications.test.tsx new file mode 100644 index 000000000..dee03e45e --- /dev/null +++ b/tests/Unit/RemoteNotifications.test.tsx @@ -0,0 +1,594 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; +import { chatSnapshot, loadTab, markThreadRead, seedThread, setConversationArchived, setConversationMute, watchRemoteAlerts, watchRemotesPage, watchThread } from '../../resources/js/lib/chat'; +import { createRemoteAlerts, isRemoteMuted, remoteAlertsEnabled, setRemoteAlerts } from '../../resources/js/lib/remote-notifications'; +import type { ConversationSummary } from '../../resources/js/types'; + +const storage = new Map(); +const shown: FakeNotification[] = []; +const play = vi.fn(async () => {}); +const assign = vi.fn(); +let permission: NotificationPermission = 'granted'; +let time = Date.parse('2026-10-22T12:00:00Z'); +const readers: ReturnType[] = []; + +class FakeNotification { + static get permission() { return permission; } + static requestPermission = async () => permission; + title: string; + options: NotificationOptions; + onclick = () => {}; + onclose = () => {}; + close = vi.fn(); + constructor(title: string, options: NotificationOptions) { + this.title = title; + this.options = options; + shown.push(this); + } +} + +function reader(reading: () => string[] = () => []) { + const alerts = createRemoteAlerts('me', (item) => `/remotes/${item.id}`, reading); + readers.push(alerts); + return alerts; +} + +function conversation(id: number, changes: Partial = {}): ConversationSummary { + return { + id: 'remote-one', kind: 'direct', name: null, partner: null, members: null, + last_message: { id, user_id: 'other', client_id: '', body: 'private message', created_at: new Date(time).toISOString() }, + last_incoming_message_id: id, + last_message_at: new Date(time).toISOString(), ahead: 1, + partner_read_through_id: 0, partner_read_at: null, is_archived: false, muted_until_at: null, + ...changes, + }; +} + +beforeEach(() => { + time = Date.parse('2026-10-22T12:00:00Z'); + permission = 'granted'; + storage.clear(); + shown.length = 0; + play.mockClear(); + assign.mockClear(); + vi.spyOn(Date, 'now').mockImplementation(() => time); + vi.stubGlobal('Notification', FakeNotification); + vi.stubGlobal('Audio', class { + play = play; + pause() {} + currentTime = 0; + muted = false; + }); + const events = new EventTarget(); + vi.stubGlobal('window', { + Notification: FakeNotification, isSecureContext: true, + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + dispatchEvent: events.dispatchEvent.bind(events), + setInterval, clearInterval, focus() {}, location: { assign }, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + vi.stubGlobal('document', { addEventListener() {}, removeEventListener() {} }); + let lock = Promise.resolve(); + vi.stubGlobal('navigator', { locks: { request: (_name: string, run: () => Promise) => (lock = lock.then(run)) } }); +}); + +afterEach(() => { + readers.splice(0).forEach((alerts) => alerts.stop()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test('ignores backlog, own messages, org events, read conversations and active mutes without replaying them later', async () => { + const alerts = reader(); + const away = () => false; + await alerts.receive([conversation(10)], away); + const own = conversation(11, { last_incoming_message_id: 10 }); + own.last_message!.user_id = 'me'; + await alerts.receive([own], away); + const event = conversation(12, { last_incoming_message_id: 10 }); + event.last_message!.event = 'added'; + await alerts.receive([event], away); + await alerts.receive([conversation(13)], () => true); + await alerts.receive([conversation(14, { ahead: 0 })], away); + await alerts.receive([conversation(15, { muted_until_at: new Date(time + 1).toISOString() })], away); + time += 20_000; + await alerts.receive([conversation(15)], away); + assert.equal(shown.length, 0); + assert.equal(play.mock.calls.length, 0); + + await alerts.receive([conversation(16, { muted_until_at: new Date(time).toISOString() })], away); + assert.equal(shown.length, 1); + assert.equal(play.mock.calls.length, 1); + assert.equal(shown[0].options.body, 'You have a new message in Remotes.'); + assert.equal(shown[0].options.silent, true); + assert.equal(JSON.stringify(shown[0].options).includes('private message'), false); + shown[0].onclick(); + assert.deepEqual(assign.mock.calls, [['/remotes/remote-one']]); +}); + +test('one ten-second cooldown covers bursts in different remotes and concurrent tabs, without delayed duplicate alerts', async () => { + const first = reader(); + const second = reader(); + await Promise.all([first.receive([], () => false), second.receive([], () => false)]); + await Promise.all([first.receive([conversation(20)], () => false), second.receive([conversation(20)], () => false)]); + time += 9999; + await first.receive([conversation(21, { id: 'another-remote' })], () => false); + assert.equal(shown.length, 1); + time++; + await second.receive([conversation(21, { id: 'another-remote' })], () => false); + assert.equal(shown.length, 1); + await second.receive([conversation(22, { kind: 'org' })], () => false); + assert.equal(shown.length, 2); + time += 10_000; + await first.receive([conversation(22)], () => false); + assert.equal(shown.length, 2); + assert.equal(play.mock.calls.length, 2); +}); + +test.each([false, true])('out-of-order arrivals across remotes are not lost to %s cross-tab deduplication', async (otherTab) => { + const first = reader(); + const second = reader(); + await first.receive([], () => false); + await second.receive([], () => false); + await first.receive([conversation(102, { id: 'fast-remote' })], () => true); + assert.equal(shown.length, 0); + + await (otherTab ? second : first).receive([conversation(101, { id: 'slow-remote' })], () => false); + assert.equal(shown.length, 1); + assert.equal(play.mock.calls.length, 1); + assert.equal(shown[0].options.data.url, '/remotes/slow-remote'); + + time += 10_000; + await (otherTab ? first : second).receive([conversation(101, { id: 'slow-remote' })], () => false); + await second.receive([conversation(102, { id: 'fast-remote' })], () => false); + assert.equal(shown.length, 1); + await first.receive([conversation(103, { id: 'slow-remote' })], () => false); + assert.equal(shown.length, 2); +}); + +test('a focused reader suppresses a background tab before its unread poll catches up, and releases focus and closed tabs', async () => { + let focused = true; + const foreground = reader(() => focused ? ['/remotes/remote-one'] : []); + const background = reader(); + await Promise.all([foreground.receive([], () => false), background.receive([], () => false)]); + + await background.receive([conversation(20)], () => false); + assert.equal(shown.length, 0); + + focused = false; + window.dispatchEvent(new Event('blur')); + await foreground.receive([conversation(20)], () => false); + assert.equal(shown.length, 0); + await background.receive([conversation(21)], () => false); + assert.equal(shown.length, 1); + + focused = true; + window.dispatchEvent(new Event('focus')); + time += 10_000; + await background.receive([conversation(22)], () => false); + assert.equal(shown.length, 1); + window.dispatchEvent(new Event('pagehide')); + await background.receive([conversation(23)], () => false); + assert.equal(shown.length, 2); + + window.dispatchEvent(new Event('pageshow')); + time += 10_000; + await background.receive([conversation(24)], () => false); + assert.equal(shown.length, 2); + foreground.stop(); + await background.receive([conversation(25)], () => false); + assert.equal(shown.length, 3); +}); + +test('a reader that stops refreshing its focused state cannot suppress alerts indefinitely', async () => { + reader(() => ['/remotes/remote-one']); + const background = reader(); + await background.receive([], () => false); + + time += 14_999; + await background.receive([conversation(20)], () => false); + assert.equal(shown.length, 0); + time++; + await background.receive([conversation(21)], () => false); + assert.equal(shown.length, 1); +}); + +test('a newer org membership event does not hide an incoming message or itself generate another alert', async () => { + const alerts = reader(); + await alerts.receive([conversation(10)], () => false); + const event = conversation(12, { kind: 'org', last_incoming_message_id: 11 }); + event.last_message!.event = 'added'; + + await alerts.receive([event], () => false); + assert.equal(shown.length, 1); + + time += 10_000; + event.last_message!.id = 13; + await alerts.receive([event], () => false); + assert.equal(shown.length, 1); +}); + +test('alerts default on, preserve an explicit opt-out per account, and banners still require permission', async () => { + assert.equal(remoteAlertsEnabled('me'), true); + assert.equal(remoteAlertsEnabled('me', 'sound'), true); + await setRemoteAlerts('me', false); + assert.equal(storage.get('remote-alerts:me:notifications'), 'off'); + assert.equal(remoteAlertsEnabled('me'), false); + assert.equal(remoteAlertsEnabled('me', 'sound'), true); + assert.equal(remoteAlertsEnabled('someone-else'), true); + permission = 'denied'; + assert.equal(await setRemoteAlerts('me', true), false); + assert.equal(remoteAlertsEnabled('me'), false); + permission = 'granted'; + assert.equal(await setRemoteAlerts('me', true), true); + const alerts = reader(); + await alerts.receive([], () => false); + permission = 'denied'; + await alerts.receive([conversation(1)], () => false); + permission = 'granted'; + const pending = alerts.receive([conversation(2)], () => false); + alerts.stop(); + await pending; + assert.equal(shown.length, 0); +}); + +test('legacy opt-outs apply to both channels until each is changed independently', async () => { + storage.set('remote-alerts:me', 'off'); + assert.equal(remoteAlertsEnabled('me'), false); + assert.equal(remoteAlertsEnabled('me', 'sound'), false); + permission = 'denied'; + const requestPermission = vi.spyOn(FakeNotification, 'requestPermission'); + + assert.equal(await setRemoteAlerts('me', true, 'sound'), true); + assert.equal(requestPermission.mock.calls.length, 0); + assert.equal(remoteAlertsEnabled('me'), false); + assert.equal(remoteAlertsEnabled('me', 'sound'), true); + + permission = 'granted'; + await setRemoteAlerts('me', true); + await setRemoteAlerts('me', false, 'sound'); + assert.equal(remoteAlertsEnabled('me'), true); + assert.equal(remoteAlertsEnabled('me', 'sound'), false); +}); + +test.each([ + [true, true], [true, false], [false, true], [false, false], +])('notifications=%s and sound=%s operate independently', async (notifications, sound) => { + await setRemoteAlerts('me', notifications); + await setRemoteAlerts('me', sound, 'sound'); + const alerts = reader(); + await alerts.receive([], () => false); + await alerts.receive([conversation(1)], () => false); + + assert.equal(shown.length, Number(notifications)); + assert.equal(play.mock.calls.length, Number(sound)); +}); + +test.each(['denied', 'unavailable', 'broken'] as const)('sound works when browser notifications are %s', async (state) => { + if (state === 'denied') permission = 'denied'; + if (state === 'unavailable') { + Reflect.deleteProperty(window, 'Notification'); + vi.stubGlobal('Notification', undefined); + } + if (state === 'broken') { + vi.stubGlobal('Notification', class extends FakeNotification { + constructor() { super('', {}); shown.pop(); throw new Error('Notifications blocked'); } + }); + } + const alerts = reader(); + await alerts.receive([], () => false); + await alerts.receive([conversation(1)], () => false); + time += 9999; + await alerts.receive([conversation(2)], () => false); + + assert.equal(shown.length, 0); + assert.equal(play.mock.calls.length, 1); +}); + +test('the default does not hide a failed preference save when storage is blocked', async () => { + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { throw new Error('Storage blocked'); }); + + assert.equal(await setRemoteAlerts('me', true), false); + assert.equal(await setRemoteAlerts('me', false), false); + assert.equal(await setRemoteAlerts('me', false, 'sound'), false); + assert.equal(remoteAlertsEnabled('me'), true); + assert.equal(remoteAlertsEnabled('me', 'sound'), true); +}); + +test('expiry is exact and forever stays muted', () => { + assert.equal(isRemoteMuted(null, time), false); + assert.equal(isRemoteMuted(new Date(time - 1).toISOString(), time), false); + assert.equal(isRemoteMuted(new Date(time).toISOString(), time), false); + assert.equal(isRemoteMuted(new Date(time + 1).toISOString(), time), true); + assert.equal(isRemoteMuted('9999-12-31T00:00:00Z', time), true); +}); + +test('mobile notification fallback works and rejected audio does not reject message polling', async () => { + vi.stubGlobal('Notification', class extends FakeNotification { + constructor() { super('', {}); shown.pop(); throw new TypeError('Use a service worker'); } + }); + const showNotification = vi.fn(async () => {}); + vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => ({ showNotification }) } }); + play.mockRejectedValueOnce(new Error('Autoplay blocked')); + const alerts = reader(); + await alerts.receive([], () => false); + await alerts.receive([conversation(1)], () => false); + assert.equal(showNotification.mock.calls.length, 1); + assert.equal(play.mock.calls.length, 1); +}); + +test.each([true, false])('background inbox polling alerts without consuming unread state, with banners=%s', async (banners) => { + vi.useFakeTimers(); + Object.assign(window, { setTimeout, clearTimeout }); + vi.stubGlobal('document', { hidden: true, cookie: '', addEventListener() {}, removeEventListener() {} }); + await setRemoteAlerts('me', banners); + let messages = [conversation(30, { kind: 'org' })]; + const requestedBaselines: boolean[] = []; + vi.stubGlobal('fetch', async (url: string) => { + const baseline = url.includes('notification_baseline=1'); + requestedBaselines.push(baseline); + return Response.json({ conversations: messages, has_more: false, unread: 1, requests: 0, ...(baseline ? { notification_baseline: { 'remote-one': 30 } } : {}) }); + }); + const stop = watchRemoteAlerts('me'); + + try { + await vi.advanceTimersByTimeAsync(0); + assert.equal(shown.length, 0); + messages = [conversation(31, { kind: 'org' })]; + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(shown.length, Number(banners)); + assert.equal(play.mock.calls.length, 1); + assert.equal(chatSnapshot().unread, 1); + assert.equal(chatSnapshot().conversations?.[0].ahead, 1); + assert.deepEqual(requestedBaselines, [true, false]); + } finally { + stop(); + vi.useRealTimers(); + } +}); + +test('restarting alerts during an inbox request waits for its own baseline without replaying old messages', async () => { + vi.useFakeTimers(); + Object.assign(window, { setTimeout, clearTimeout }); + Object.assign(document, { hidden: true, cookie: '' }); + let resolveOld: ((response: Response) => void) | undefined; + let id = 80; + const requestedBaselines: boolean[] = []; + vi.stubGlobal('fetch', async (url: string) => { + const baseline = url.includes('notification_baseline=1'); + requestedBaselines.push(baseline); + if (requestedBaselines.length === 1) return new Promise((resolve) => { resolveOld = resolve; }); + return Response.json({ conversations: [conversation(id, { id: 'restarted', kind: 'org' })], has_more: false, unread: 1, requests: 0, ...(baseline ? { notification_baseline: { restarted: 80 } } : {}) }); + }); + const stopOld = watchRemoteAlerts('me'); + stopOld(); + const stop = watchRemoteAlerts('me'); + try { + assert.ok(resolveOld); + resolveOld(Response.json({ conversations: [], notification_baseline: {}, has_more: false, unread: 0, requests: 0 })); + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(shown.length, 0); + assert.equal(play.mock.calls.length, 0); + id = 81; + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(shown.length, 1); + assert.equal(play.mock.calls.length, 1); + assert.deepEqual(requestedBaselines, [true, true, false]); + } finally { + stop(); + vi.useRealTimers(); + } +}); + +test('an inbox response from before a successful mute cannot alert or replay after unmuting', async () => { + vi.useFakeTimers(); + Object.assign(window, { setTimeout, clearTimeout }); + Object.assign(document, { hidden: true, cookie: '' }); + const target = { kind: 'org' as const, org: { id: 'mute-race', name: 'Team' } }; + let messages: ConversationSummary[] = []; + let resolveInbox: ((response: Response) => void) | undefined; + let delayInbox = false; + vi.stubGlobal('fetch', async (_url: string, options?: RequestInit) => { + if (options?.method === 'PUT') { + return Response.json({ muted_until_at: JSON.parse(options.body as string).duration === 'off' ? null : '9999-12-31T00:00:00Z' }); + } + if (delayInbox) return new Promise((resolve) => { resolveInbox = resolve; }); + return Response.json({ conversations: messages, notification_baseline: {}, has_more: false, unread: messages.length, requests: 0 }); + }); + const stop = watchRemoteAlerts('me'); + try { + await vi.advanceTimersByTimeAsync(0); + delayInbox = true; + await vi.advanceTimersByTimeAsync(15_000); + assert.ok(resolveInbox); + await setConversationMute(target, 'forever'); + messages = [conversation(70, { id: 'mute-race', kind: 'org' })]; + resolveInbox(Response.json({ conversations: messages, has_more: false, unread: 1, requests: 0 })); + await vi.advanceTimersByTimeAsync(0); + assert.equal(shown.length, 0); + assert.equal(play.mock.calls.length, 0); + + delayInbox = false; + await setConversationMute(target, 'off'); + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(shown.length, 0); + messages = [conversation(71, { id: 'mute-race', kind: 'org' })]; + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(shown.length, 1); + assert.equal(play.mock.calls.length, 1); + } finally { + stop(); + vi.useRealTimers(); + } +}); + +test('muting while an alert waits for the cross-tab lock suppresses it', async () => { + vi.useFakeTimers(); + Object.assign(window, { setTimeout, clearTimeout }); + Object.assign(document, { hidden: true, cookie: '' }); + const target = { kind: 'org' as const, org: { id: 'lock-mute', name: 'Team' } }; + let messages: ConversationSummary[] = []; + let releaseAlert: (() => Promise) | undefined; + vi.stubGlobal('navigator', { locks: { request: (_name: string, notify: () => Promise) => { + releaseAlert = notify; + } } }); + vi.stubGlobal('fetch', async (_url: string, options?: RequestInit) => { + if (options?.method === 'PUT') return Response.json({ muted_until_at: '9999-12-31T00:00:00Z' }); + return Response.json({ conversations: messages, notification_baseline: {}, has_more: false, unread: messages.length, requests: 0 }); + }); + const stop = watchRemoteAlerts('me'); + try { + await vi.advanceTimersByTimeAsync(0); + messages = [conversation(75, { id: 'lock-mute', kind: 'org' })]; + await vi.advanceTimersByTimeAsync(15_000); + assert.ok(releaseAlert); + await setConversationMute(target, 'forever'); + await releaseAlert(); + assert.equal(shown.length, 0); + assert.equal(play.mock.calls.length, 0); + } finally { + stop(); + vi.useRealTimers(); + } +}); + +test('popping an unread stash without opening its thread does not replay old messages, but a new delivery alerts', async () => { + vi.useFakeTimers(); + Object.assign(window, { setTimeout, clearTimeout }); + Object.assign(document, { hidden: true, cookie: '' }); + const target = { kind: 'org' as const, org: { id: 'old-stash', name: 'Old stash' } }; + let archived = true; + let id = 80; + vi.stubGlobal('fetch', async (url: string, options?: RequestInit) => { + if (options?.method === 'DELETE') { + archived = false; + return new Response(null, { status: 204 }); + } + const stashed = url.includes('tab=stashed'); + return Response.json({ + conversations: stashed === archived ? [conversation(id, { id: 'old-stash', kind: 'org', is_archived: archived })] : [], + ...(url.includes('notification_baseline=1') ? { notification_baseline: { 'old-stash': id } } : {}), + has_more: false, unread: archived ? 0 : 1, requests: 0, + }); + }); + const stop = watchRemoteAlerts('me'); + try { + await vi.advanceTimersByTimeAsync(0); + await loadTab('stashed'); + assert.equal(await setConversationArchived(target, false), true); + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(chatSnapshot().threads['org:old-stash'], undefined); + assert.equal(chatSnapshot().unread, 1); + assert.equal(shown.length, 0); + assert.equal(play.mock.calls.length, 0); + + id = 81; + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(shown.length, 1); + assert.equal(play.mock.calls.length, 1); + } finally { + stop(); + vi.useRealTimers(); + } +}); + +test.each(['blur', 'hidden', 'leave'] as const)('the focused Remotes page silences other chats across tabs without reading them, until %s', async (release) => { + vi.useFakeTimers(); + Object.assign(window, { setTimeout, clearTimeout, setInterval, clearInterval }); + let focused = true; + Object.assign(document, { hidden: false, hasFocus: () => focused, cookie: '' }); + let id = 90; + vi.stubGlobal('fetch', async (url: string) => url.includes('/inbox') + ? Response.json({ conversations: [conversation(id, { id: 'chat-b', kind: 'org' })], notification_baseline: { 'chat-b': 90 }, has_more: false, unread: 1, requests: 0 }) + : new Response(null, { status: 503 })); + const background = reader(); + await background.receive([], () => false); + let stopPage: (() => void) | undefined = watchRemotesPage(); + const stopAlerts = watchRemoteAlerts('me'); + const stopThread = watchThread({ kind: 'org', org: { id: 'chat-a', name: 'Chat A' } }); + try { + await vi.advanceTimersByTimeAsync(0); + id = 91; + await vi.advanceTimersByTimeAsync(15_000); + await background.receive([conversation(92, { id: 'chat-c' })], () => false); + assert.equal(shown.length, 0); + assert.equal(play.mock.calls.length, 0); + assert.equal(chatSnapshot().unread, 1); + assert.equal(chatSnapshot().conversations?.find((item) => item.id === 'chat-b')?.ahead, 1); + + if (release === 'leave') { + stopPage(); + stopPage = undefined; + } else { + focused = false; + Object.assign(document, { hidden: release === 'hidden' }); + window.dispatchEvent(new Event('blur')); + } + await background.receive([conversation(92, { id: 'chat-c' })], () => false); + assert.equal(shown.length, 0); + await background.receive([conversation(93, { id: 'chat-c' })], () => false); + assert.equal(shown.length, 1); + await vi.advanceTimersByTimeAsync(10_000); + id = 94; + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(shown.length, 2); + assert.equal(play.mock.calls.length, 2); + } finally { + stopPage?.(); + stopThread(); + stopAlerts(); + vi.useRealTimers(); + } +}); + +test('opening and closing a remote publishes reading state even while its requests fail', async () => { + vi.useFakeTimers(); + Object.assign(window, { setTimeout, clearTimeout }); + Object.assign(document, { hidden: false, hasFocus: () => true, cookie: '' }); + vi.stubGlobal('fetch', async () => new Response(null, { status: 503 })); + const background = createRemoteAlerts('me', (item) => `/remotes/orgs/${item.id}`, () => []); + readers.push(background); + await background.receive([], () => false); + const stopAlerts = watchRemoteAlerts('me'); + const stopThread = watchThread({ kind: 'org', org: { id: 'reading-org', name: 'Team' } }); + + try { + try { + await vi.advanceTimersByTimeAsync(0); + await background.receive([conversation(60, { id: 'reading-org', kind: 'org' })], () => false); + assert.equal(shown.length, 0); + } finally { + stopThread(); + } + + await background.receive([conversation(61, { id: 'reading-org', kind: 'org' })], () => false); + assert.equal(shown.length, 1); + } finally { + stopAlerts(); + vi.useRealTimers(); + } +}); + +test('an unfocused window does not mark incoming messages read before it can alert', async () => { + let focused = false; + vi.stubGlobal('document', { hidden: false, hasFocus: () => focused, cookie: '' }); + vi.stubGlobal('fetch', async () => new Response(null, { status: 204 })); + const target = { kind: 'org' as const, org: { id: 'unfocused-org', name: 'Team' } }; + seedThread(target, { + messages: [conversation(50).last_message!], has_older: false, + reactions_version: 0, reaction_changes: [], read_through_id: 0, + members: [], typing: [], is_archived: false, muted_until_at: null, + role: 'contributor', access: 'open', + }); + + markThreadRead(target); + assert.equal(chatSnapshot().threads['org:unfocused-org'].read_through_id, 0); + focused = true; + markThreadRead(target); + assert.equal(chatSnapshot().threads['org:unfocused-org'].read_through_id, 50); +}); diff --git a/tests/Unit/RemotePreferences.test.tsx b/tests/Unit/RemotePreferences.test.tsx new file mode 100644 index 000000000..557821bd3 --- /dev/null +++ b/tests/Unit/RemotePreferences.test.tsx @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; +import type { ConversationSummary, OrgThread, User } from '../../resources/js/types'; + +const cleanups: (() => void)[] = []; +vi.doMock('react', () => ({ + ...React, + useState: (value: unknown) => [value, () => {}], + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => snapshot(), + useEffect: (effect: () => (() => void) | undefined) => { + const cleanup = effect(); + if (cleanup) cleanups.push(cleanup); + }, +})); + +const { chatSnapshot, loadTab, searchConversations, seedThread, setConversationMute, watchThread } = await import('../../resources/js/lib/chat'); +const { useRemoteAlerts } = await import('../../resources/js/hooks/use-chat'); +const { ConversationActions } = await import('../../resources/js/components/chat/conversation-actions'); +const { remoteNotificationsAllowed, setRemoteAlerts, subscribeRemoteAlerts } = await import('../../resources/js/lib/remote-notifications'); + +const user = { id: 'viewer', username: 'viewer' } as User; +const partner = { id: 'search-partner', username: 'search-partner' } as User; +const forever = '9999-12-31T00:00:00Z'; +const org = { id: 'stashed-org', name: 'Stashed org' }; +const target = { kind: 'org' as const, org }; +const orgSummary: ConversationSummary = { + id: org.id, kind: 'org', name: org.name, partner: null, members: [], + last_message: null, last_message_at: null, last_incoming_message_id: null, + ahead: 0, partner_read_through_id: 0, partner_read_at: null, is_archived: true, muted_until_at: null, +}; +const thread: OrgThread & { org: typeof org } = { + org, messages: [], has_older: false, reactions_version: 0, reaction_changes: [], + read_through_id: 0, members: [], typing: [], is_archived: true, muted_until_at: null, + role: 'contributor', access: 'open', +}; + +function muteItem(props: Parameters[0]) { + const elements = (node: ReactNode): ReactElement>[] => { + if (!React.isValidElement(node)) return []; + const element = node as ReactElement>; + return [element, ...React.Children.toArray(element.props.children as ReactNode).flatMap(elements)]; + }; + const item = elements(ConversationActions(props)).find((element) => + React.Children.toArray(element.props.children as ReactNode).some((child) => child === 'Mute' || child === 'Unmute'), + ); + assert.ok(item); + return { disabled: item.props.disabled, label: React.Children.toArray(item.props.children as ReactNode).at(-1) }; +} + +beforeEach(() => { + vi.useFakeTimers(); + let clock = 0; + vi.spyOn(performance, 'now').mockImplementation(() => ++clock); + const storage = new Map(); + const events = new EventTarget(); + vi.stubGlobal('window', { + setTimeout, clearTimeout, setInterval, clearInterval, isSecureContext: true, + Notification: {}, + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + dispatchEvent: events.dispatchEvent.bind(events), + localStorage: { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value) }, + }); + vi.stubGlobal('document', { hidden: false, cookie: '', hasFocus: () => false, addEventListener() {}, removeEventListener() {} }); + vi.stubGlobal('navigator', {}); + vi.stubGlobal('Notification', { permission: 'default', requestPermission: async () => Notification.permission }); + vi.stubGlobal('Audio', class { play = async () => {}; pause() {} }); +}); + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test('a search-only direct remote can be muted and unmuted without opening its thread', async () => { + const summary = { ...orgSummary, id: 'search-only', kind: 'direct' as const, partner, is_archived: false }; + const direct = { kind: 'direct' as const, partner }; + const props = { target: direct, title: partner.username, user, isArchived: false }; + assert.equal(muteItem(props).disabled, true); + vi.stubGlobal('fetch', async (_url: string, options?: RequestInit) => Response.json( + options?.method === 'PUT' + ? { muted_until_at: JSON.parse(options.body as string).duration === 'off' ? null : forever } + : { conversations: [summary] }, + )); + + const [found] = await searchConversations('search-partner', 'open', new AbortController().signal); + assert.equal(chatSnapshot().threads[partner.username], undefined); + assert.deepEqual(muteItem({ ...props, conversation: found }), { disabled: false, label: 'Mute' }); + await setConversationMute(direct, 'forever'); + assert.deepEqual(muteItem({ ...props, conversation: found }), { disabled: false, label: 'Unmute' }); + await setConversationMute(direct, 'off'); + assert.deepEqual(muteItem({ ...props, conversation: found }), { disabled: false, label: 'Mute' }); +}); + +test('fresh thread mute changes beat a stashed summary and older in-flight inbox reads, but not newer reads', async () => { + const props = { target, title: org.name!, user, isArchived: true }; + vi.stubGlobal('fetch', async () => Response.json({ conversations: [orgSummary], has_more: false, unread: 0, requests: 0 })); + await loadTab('stashed'); + seedThread(target, thread); + let resolveInbox!: (response: Response) => void; + let muted: string | null = forever; + vi.stubGlobal('fetch', async (url: string) => url.includes('/inbox') + ? new Promise((resolve) => { resolveInbox = resolve; }) + : Response.json({ ...thread, muted_until_at: muted })); + const staleInbox = loadTab('stashed'); + const stop = watchThread(target); + cleanups.push(stop); + await vi.advanceTimersByTimeAsync(0); + assert.equal(muteItem(props).label, 'Unmute'); + resolveInbox(Response.json({ conversations: [orgSummary], has_more: false, unread: 0, requests: 0 })); + await staleInbox; + assert.equal(muteItem(props).label, 'Unmute'); + + muted = null; + await vi.advanceTimersByTimeAsync(4000); + assert.equal(muteItem(props).label, 'Mute'); + vi.stubGlobal('fetch', async () => Response.json({ conversations: [{ ...orgSummary, muted_until_at: forever }], has_more: false, unread: 0, requests: 0 })); + await loadTab('stashed'); + assert.equal(muteItem(props).label, 'Unmute'); +}); + +test.each(['default', 'denied', 'unsupported', 'granted'] as const)('banner-only polling requires permission: %s', async (permission) => { + if (permission === 'unsupported') Reflect.deleteProperty(window, 'Notification'); + else vi.stubGlobal('Notification', { permission }); + await setRemoteAlerts('viewer', false, 'sound'); + const fetch = vi.fn(async () => Response.json({ conversations: [], has_more: false, unread: 0, requests: 0 })); + vi.stubGlobal('fetch', fetch); + + useRemoteAlerts('viewer'); + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(fetch.mock.calls.length, permission === 'granted' ? 2 : 0); +}); + +test('sound-only polling works without notification permission', async () => { + await setRemoteAlerts('viewer', false); + const fetch = vi.fn(async () => Response.json({ conversations: [], has_more: false, unread: 0, requests: 0 })); + vi.stubGlobal('fetch', fetch); + useRemoteAlerts('viewer'); + await vi.advanceTimersByTimeAsync(15_000); + assert.equal(fetch.mock.calls.length, 2); +}); + +test('permission changes and returning to the tab refresh permission subscribers without a reload', async () => { + const status = new EventTarget(); + vi.stubGlobal('navigator', { permissions: { query: async () => status } }); + const permissions: boolean[] = []; + const unsubscribe = subscribeRemoteAlerts(() => permissions.push(remoteNotificationsAllowed())); + await Promise.resolve(); + vi.stubGlobal('Notification', { permission: 'granted' }); + status.dispatchEvent(new Event('change')); + vi.stubGlobal('Notification', { permission: 'denied' }); + window.dispatchEvent(new Event('focus')); + unsubscribe(); + status.dispatchEvent(new Event('change')); + assert.deepEqual(permissions, [false, true, false]); +}); diff --git a/tests/draft-images.config.ts b/tests/draft-images.config.ts index 1523572f0..1c958bf95 100644 --- a/tests/draft-images.config.ts +++ b/tests/draft-images.config.ts @@ -46,6 +46,8 @@ export default defineConfig({ 'tests/Unit/VoiceSpeed.test.tsx', 'tests/Unit/VoiceNoteSpeed.test.tsx', 'tests/Unit/OrgMentionSuggestions.test.tsx', + 'tests/Unit/RemoteNotifications.test.tsx', + 'tests/Unit/RemotePreferences.test.tsx', ], }, }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index da2bb1f52..ada238b4b 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -24,6 +24,8 @@ "Unit/VoiceSpeed.test.tsx", "Unit/VoiceNoteSpeed.test.tsx", "Unit/OrgMentionSuggestions.test.tsx", + "Unit/RemoteNotifications.test.tsx", + "Unit/RemotePreferences.test.tsx", "../resources/js/types/*.d.ts", "draft-images.config.ts" ]