diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index de152c881..966427259 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -240,7 +240,7 @@ Sources: [composer](resources/js/components/commit-composer.tsx), [issue command ### Stashed drafts -The app stashes unfinished commit and reply text, attached images, and image descriptions locally in your browser so you can return to them. Drafts belong to the signed-in user on that browser; they are not cloud drafts and do not synchronize between devices. Publishing or dropping a draft clears that stash. Browser storage restrictions or clearing site data can remove it; do not use the stash as your only copy of important writing. +The app stashes unfinished commit and reply text, attached images, image descriptions, and reply recipient exclusions locally in your browser so you can return to them. Drafts belong to the signed-in user on that browser; they are not cloud drafts and do not synchronize between devices. Publishing or dropping a draft clears that stash. Browser storage restrictions or clearing site data can remove it; do not use the stash as your only copy of important writing. Sources: [stash hook](resources/js/hooks/use-stash.ts), [image storage](resources/js/lib/image-storage.ts). @@ -747,7 +747,7 @@ Originally, Tom had no particular purpose beyond that nod to MySpace. He now has Tom's lookups start with **OpenAI's Decisions API**, which evaluates simple criteria for each read-only tool using the latest message and the same bounded public thread context. Finding social posts and community reactions uses post search; factual platform questions and notable events, including milestones, downtime and security patches, use the knowledge base; current repository numbers and contribution opportunities use repository insights; feature development and attribution questions use repository history; and supported member counts use community statistics. Sources are complementary: a question about a feature's behavior, evolution and community reception can use the knowledge base, posts and repository history together, without needing to request each search explicitly. Tom compiles the relevant evidence into one answer, distinguishing documented facts, community opinions and development history. An inconclusive search can lead to a follow-up with different keywords, and each new reply has a fresh search allowance. When no tool is needed, such as small talk, ambiguous requests or anything outside his role, Tom simply replies as regular chat. -Tag **@tom** in a public commit or reply, or reply directly to one of his posts without tagging him. Once it is approved, Tom can reply under that commit, at most once, addressing its author rather than the thread's root poster. Replies further down the thread still need to tag him unless their direct parent is a Tom post. Mentions in code do not summon him. His replies use the normal commit moderation and notification pipeline. He does not reply to himself, across a block, in private messages, or to deleted or unapproved commits. +Tag **@tom** in a public commit or reply, or reply directly to one of his posts without tagging him. Once it is approved, Tom can reply under that commit, at most once, addressing its author rather than the thread's root poster. Replies further down the thread still need to tag him unless their direct parent is a Tom post. Mentions in code do not summon him. Selecting him as a reply notification recipient does not summon him either. His replies use the normal commit moderation and notification pipeline. He does not reply to himself, across a block, in private messages, or to deleted or unapproved commits. If the real GitHub owner of `tom` signs in, the bot moves to **@devnull-tom** with the same profile, posts and friendships. Mention **@devnull-tom** to summon him after that; direct replies and mentions captured before the rename still reach the bot. New mentions of the human **@tom** do not summon him. He remains every newcomer's first friend. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index fe8a0259e..00d84b8e9 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -51,8 +51,9 @@ public function __construct( * * @param list $images * @param array $imageDescriptions + * @param array $replyRecipients */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = []): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -60,7 +61,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -68,7 +69,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $commit = $user->commits()->create([ 'body' => $this->emojiShortcodes->replace($body), - 'mentions' => $mentions, + 'mentions' => [...$replyRecipients, ...$mentions], 'parent_id' => $parent?->id, 'root_id' => $parent?->threadRootId(), 'status' => $user->isAdmin() || $hasGoodReputation ? CommitStatus::Approved : CommitStatus::Pending, diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index e7da33c70..7cc5bcc73 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -24,9 +24,10 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u return response()->json($replies->paginate($commit, $user, $request->boolean('newest'), $request->integer('limit', CommitRepliesQuery::PER_PAGE), $request->exceptedReplyId())); } + /** Creates a reply addressed to the parent author and the validated selection of conversation participants. */ public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse { - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview()); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients()); $parent = $thread->get($commit, $user); return response()->json([ diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index da3487990..d862583ed 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -7,6 +7,7 @@ use App\Actions\CreateCommitImages; use App\Models\Commit; use App\Models\User; +use App\Queries\MentionedUsersQuery; use App\Services\Urls; use Illuminate\Auth\Access\Response; use Illuminate\Contracts\Validation\ValidationRule; @@ -16,9 +17,12 @@ use Illuminate\Validation\Rules\Dimensions; use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\In; +use Illuminate\Validation\Validator; final class CreateCommitReplyRequest extends FormRequest { + public const int MAX_REPLY_RECIPIENTS = 100; + public function authorize(): Response { $user = $this->user(); @@ -43,8 +47,12 @@ public function prepareForValidation(): void public function rules(): array { $body = $this->input('body'); + $parent = $this->route('commit'); + assert($parent instanceof Commit); return [ + 'reply_recipients' => ['sometimes', 'list', 'max:'.self::MAX_REPLY_RECIPIENTS], + 'reply_recipients.*' => ['required', 'string', 'distinct', Rule::in(array_values($parent->mentions ?? []))], 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], 'images' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], @@ -61,6 +69,26 @@ public function rules(): array ]; } + /** + * Bound the combined inherited and explicit recipients so reply chains cannot grow notification fanout indefinitely. + * + * @return array + */ + public function after(): array + { + return [function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $recipients = [...$this->replyRecipients(), ...resolve(MentionedUsersQuery::class)->get($this->string('body')->value())]; + + if (count(array_unique($recipients)) > self::MAX_REPLY_RECIPIENTS) { + $validator->errors()->add('reply_recipients', 'A reply may include at most '.self::MAX_REPLY_RECIPIENTS.' participants across selected recipients and explicit mentions.'); + } + }]; + } + /** * @return array */ @@ -103,6 +131,22 @@ public function images(): array return is_array($images) ? array_values($images) : []; } + /** + * Only people already addressed by the parent may be included without an explicit body mention. + * + * @return array + */ + public function replyRecipients(): array + { + $parent = $this->route('commit'); + assert($parent instanceof Commit); + + /** @var list $recipients */ + $recipients = $this->validated('reply_recipients', []); + + return array_intersect($parent->mentions ?? [], $recipients); + } + /** * @return list */ diff --git a/app/Models/Commit.php b/app/Models/Commit.php index e91a35d4c..fd9d1dff2 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -179,7 +179,9 @@ public function mentionedNonUsers(): array public function isAddressedTo(User $user): bool { - return in_array($user->id, $this->mentions ?? [], true) || $this->parent?->user_id === $user->id; + $explicitMentions = array_intersect_key($this->mentions ?? [], array_flip(resolve(Mentions::class)->handles($this->body))); + + return in_array($user->id, $explicitMentions, true) || $this->parent?->user_id === $user->id; } /** diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index 6ac816c3d..0095a5b84 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -54,6 +54,7 @@ import InputError from '@/components/input-error'; import { LinkCardPicker } from '@/components/link-card-picker'; import { LinkPreview } from '@/components/link-preview'; import { MentionTextarea } from '@/components/mention-textarea'; +import { ReplyRecipientPicker } from '@/components/reply-recipient-picker'; import { StashLabel } from '@/components/stash-label'; import { showTimeout, timeoutFrom } from '@/components/timeout-dialog'; import { TimestampTooltip } from '@/components/timestamp-tooltip'; @@ -679,6 +680,7 @@ export function ThreadReplyComposer({ thread }: { thread: Thread }) { return thread.isReplying && !isDeleted(commit) ? ( [ + id, + handle, + ]), + ] + .filter(([id]) => id !== user.id) + .map(([id, handle]) => [id, { id, handle }]), + ).values(), + ); + const hasRecipientChoice = participants.some( + ({ id }) => id !== parent.user.id, + ); const { body, images, descriptions, isRestored, key: stashKey, + excludedRecipientIds, + updateExcludedRecipientIds, updateBody, updateImages, drop, @@ -1367,6 +1388,7 @@ function ReplyComposer({ body: string; images: File[]; image_descriptions: string[]; + reply_recipients: string[]; link_preview_url?: string; commit_rules_accepted?: boolean; }, @@ -1375,6 +1397,7 @@ function ReplyComposer({ body: '', images: [], image_descriptions: [], + reply_recipients: [], }); const textareaRef = useRef(null); const commitRules = useCommitRules(); @@ -1435,6 +1458,13 @@ function ReplyComposer({ body, images, image_descriptions: descriptions, + reply_recipients: participants + .filter( + ({ id }) => + id !== parent.user.id && + !excludedRecipientIds.includes(id), + ) + .map(({ id }) => id), commit_rules_accepted: commitRules.hasAccepted(), ...(linkCard.isChosen ? { link_preview_url: linkCard.link ?? '' } @@ -1484,6 +1514,14 @@ function ReplyComposer({ + {hasRecipientChoice && ( + + )}
+ key.startsWith('reply_recipients.'), + )?.[1] ?? + errors.link_preview_url + } className="px-1" /> {!preview.isPreviewing && ( diff --git a/resources/js/components/reply-recipient-picker.tsx b/resources/js/components/reply-recipient-picker.tsx new file mode 100644 index 000000000..d22eaa571 --- /dev/null +++ b/resources/js/components/reply-recipient-picker.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +export type ReplyParticipant = { id: string; handle: string }; + +/** Lets the author choose additional reply recipients while keeping the parent author selected. */ +export function ReplyRecipientPicker({ + participants, + authorId, + excludedIds, + onExcludedIdsChange, +}: { + participants: ReplyParticipant[]; + authorId: string; + excludedIds: string[]; + onExcludedIdsChange: (ids: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const selected = participants.filter(({ id }) => !excludedIds.includes(id)); + + return ( + + + + + + + Replying to + + Choose who to include. The commit author stays selected. + Anyone you mention in your reply will also be notified. + + +
+ {participants.map(({ id, handle }) => ( + + ))} +
+ +
+
+ ); +} diff --git a/resources/js/hooks/use-stash.ts b/resources/js/hooks/use-stash.ts index a1d76c86e..11b9e0240 100644 --- a/resources/js/hooks/use-stash.ts +++ b/resources/js/hooks/use-stash.ts @@ -20,6 +20,8 @@ export type UseStashReturn = { readonly images: File[]; readonly descriptions: string[]; readonly isRestored: boolean; + readonly excludedRecipientIds: string[]; + readonly updateExcludedRecipientIds: (ids: string[]) => void; readonly updateBody: (body: string) => void; readonly updateImages: (images: File[], descriptions?: string[]) => void; readonly drop: () => void; @@ -208,6 +210,14 @@ export function useHasStash(userId: string | null, name: string): boolean { export function useStash(userId: string, name: string): UseStashReturn { const key = keyFor(userId, name); const [body, setBody] = useState(() => read(key)); + const recipientKey = `${key}:excluded-recipients`; + const [excludedRecipientIds, setExcludedRecipientIds] = useState(() => + readStorage(recipientKey).split(',').filter(Boolean), + ); + const updateExcludedRecipientIds = (ids: string[]): void => { + setExcludedRecipientIds(ids); + writeStorage(recipientKey, ids.join(',')); + }; const { draftImages, descriptions, isImagesRestored } = useDraftImages(key); const [isDraftRestored, setIsDraftRestored] = useState( () => body !== '' || draftImages.length > 0, @@ -240,6 +250,7 @@ export function useStash(userId: string, name: string): UseStashReturn { const drop = (): void => { cancelImageRead(key); setBody(''); + updateExcludedRecipientIds([]); setIsDraftRestored(false); discardBody(key); storeImages(key, []); @@ -253,6 +264,8 @@ export function useStash(userId: string, name: string): UseStashReturn { isRestored: (isDraftRestored || isImagesRestored) && (body !== '' || draftImages.length > 0), + excludedRecipientIds, + updateExcludedRecipientIds, updateBody, updateImages, drop, diff --git a/tests/Browser/fixtures/reply-composer.tsx b/tests/Browser/fixtures/reply-composer.tsx new file mode 100644 index 000000000..475f3f462 --- /dev/null +++ b/tests/Browser/fixtures/reply-composer.tsx @@ -0,0 +1,52 @@ +import { createInertiaApp } from '@inertiajs/react'; +import { createRoot } from 'react-dom/client'; +import { + ThreadReplyComposer, + useThread, +} from '../../../resources/js/components/commit-thread'; +import { TooltipProvider } from '../../../resources/js/components/ui/tooltip'; +import type { Commit } from '../../../resources/js/types'; + +const commit = { + id: 'own-commit', + user_id: 'author', + user: { id: 'author', handle: 'author', name: 'Author' }, + mentions: { + author: 'author', + excluded: 'excluded-user', + retained: 'retained-user', + }, + replies_count: 0, + status: 'approved', +} as unknown as Commit; + +function Composer() { + const thread = useThread(commit); + return ( + + + + ); +} + +await createInertiaApp({ + page: { + component: 'test', + props: { + auth: { user: commit.user, log: {} }, + errors: {}, + repliesPerPage: 3, + }, + url: '/', + version: null, + clearHistory: false, + encryptHistory: false, + rescuedProps: [], + rememberedState: {}, + flash: {}, + }, + resolve: () => Composer, + setup: ({ el, App, props }) => { + if (el) createRoot(el).render(); + }, +}); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 2e9b9fedc..633576dd8 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -8,6 +8,7 @@ use App\Ai\Agents\Tom; use App\Contracts\Services\ToolDecisionProvider; use App\Enums\CommitStatus; +use App\Http\Requests\CreateCommitReplyRequest; use App\Jobs\DetectCommitLanguage; use App\Jobs\ReplyAsTom; use App\Jobs\UnfurlLink; @@ -19,6 +20,70 @@ use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; +it('bounds the combined inherited and explicit reply recipients', function (int $inheritedCount, bool $accepted): void { + Queue::fake(); + config(['app.admins' => ['alice']]); + $author = User::factory()->create(['handle' => 'alice']); + $users = User::factory()->count($inheritedCount)->create(); + $mentions = $users->mapWithKeys(fn (User $user): array => [$user->handle => $user->id])->all(); + $participant = User::factory()->create(['handle' => 'new-participant']); + $parent = Commit::factory()->create(['mentions' => $mentions]); + + $response = $this->actingAs($author)->postJson(route('commit-reply.store', $parent), [ + 'body' => 'Hello @new-participant @'.$users->firstOrFail()->handle, + 'reply_recipients' => array_values($mentions), + ]); + + if ($accepted) { + $response->assertCreated(); + $reply = Commit::query()->findOrFail($response->json('committed.id')); + expect($reply->mentions)->toHaveCount(CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS); + } else { + $response->assertUnprocessable()->assertJsonValidationErrors('reply_recipients'); + expect($parent->replies()->count())->toBe(0); + expect($participant->notifications()->count())->toBe(0); + } +})->with([ + 'at limit with overlapping explicit mention' => [CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS - 1, true], + 'combined recipients over limit' => [CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS, false], + 'inherited recipients over limit' => [CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS + 1, false], +]); + +it('only notifies selected conversation recipients and keeps explicit mentions', function (bool $selected, bool $explicit): void { + Queue::fake(); + config(['app.admins' => ['alice']]); + $author = User::factory()->create(['handle' => 'alice']); + $participant = User::factory()->create(['handle' => 'participant']); + $parent = Commit::factory()->create(['mentions' => ['participant' => $participant->id]]); + + $response = $this->actingAs($author)->postJson(route('commit-reply.store', $parent), [ + 'body' => $explicit ? 'Hello @participant' : 'Hello everyone', + 'reply_recipients' => $selected ? [$participant->id] : [], + ])->assertCreated(); + + $reply = Commit::query()->findOrFail($response->json('committed.id')); + expect($reply->mentions ?? [])->toBe($selected || $explicit ? ['participant' => $participant->id] : []); + expect($participant->notifications()->where('commit_id', $reply->id)->count())->toBe($selected || $explicit ? 1 : 0); + expect($parent->user->notifications()->where('commit_id', $reply->id)->count())->toBe(1); +})->with([ + 'selected' => [true, false], + 'unchecked' => [false, false], + 'explicit mention overrides unchecked' => [false, true], +]); + +it('rejects reply recipients outside the conversation', function (): void { + $parent = Commit::factory()->create(); + $stranger = User::factory()->create(); + + $this->actingAs(User::factory()->create()) + ->postJson(route('commit-reply.store', $parent), [ + 'body' => 'Hello everyone', + 'reply_recipients' => [$stranger->id], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('reply_recipients.0'); +}); + it('answers a casual public Tom mention in the same thread', function (): void { config(['app.admins' => ['alice', 'tom'], 'ai.providers.opencode.key' => 'test', 'ai.providers.openai.key' => 'test']); Queue::fake([ReplyAsTom::class, DetectCommitLanguage::class]); diff --git a/tests/Unit/Actions/CreateTomReplyTest.php b/tests/Unit/Actions/CreateTomReplyTest.php index 8f2e8f73a..f327b42b5 100644 --- a/tests/Unit/Actions/CreateTomReplyTest.php +++ b/tests/Unit/Actions/CreateTomReplyTest.php @@ -292,3 +292,34 @@ Tom::assertNeverPrompted(); expect($commit->replies()->count())->toBe(0); })->with([true, false]); + +it('notifies selected Tom recipients but only invokes him for an explicit body mention', function (bool $approved, string $body, bool $invoked): void { + Queue::fake([ReplyAsTom::class]); + Tom::fake()->preventStrayPrompts(); + $tom = User::factory()->create(['handle' => 'tom', 'github_id' => CreateTomFriendship::GITHUB_ID]); + $parent = Commit::factory()->create(['body' => '@tom hello', 'mentions' => ['tom' => $tom->id]]); + $user = User::factory()->create(); + config(['app.admins' => $approved ? [$user->handle] : []]); + + $commit = resolve(CreateCommit::class)->handle($user, $body, parent: $parent, replyRecipients: ['tom' => $tom->id]); + + if (! $approved) { + Queue::assertNotPushed(ReplyAsTom::class); + resolve(UpdateCommitStatuses::class)->handle([$commit->id], CommitStatus::Approved); + } + + expect($commit->refresh()->mentions)->toBe(['tom' => $tom->id]) + ->and($commit->isAddressedTo($tom))->toBe($invoked); + expect($tom->notifications()->where('commit_id', $commit->id)->exists())->toBeTrue(); + Queue::assertPushed(ReplyAsTom::class, $invoked ? 1 : 0); + + if (! $invoked) { + resolve(CreateTomReply::class)->handle($commit->id); + Tom::assertNeverPrompted(); + expect($commit->replies()->count())->toBe(0); + } +})->with([true, false])->with([ + 'notification only' => ['Thanks for explaining!', false], + 'code mention' => ['This is code: `@tom`', false], + 'explicit mention' => ['@ToM what do you think?', true], +]); diff --git a/tests/Unit/CommitThread.test.tsx b/tests/Unit/CommitThread.test.tsx index fff3f7fd8..8213fa82b 100644 --- a/tests/Unit/CommitThread.test.tsx +++ b/tests/Unit/CommitThread.test.tsx @@ -7,6 +7,7 @@ import { test } from 'vite-plus/test'; import { ThreadActions, ThreadReplies, + ThreadReplyComposer, useThread, } from '../../resources/js/components/commit-thread'; import { TooltipProvider } from '../../resources/js/components/ui/tooltip'; @@ -91,6 +92,58 @@ const text = (html: string) => .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' '); +async function renderReplyComposer(mentions: Record | null) { + const commit = { ...reply('own-commit'), mentions }; + + function Preview() { + const thread = useThread(commit); + return ( + + + + ); + } + + const result = await createInertiaApp({ + page: { + component: 'test', + props: { + auth: { user: commit.user, log: {} }, + errors: {}, + repliesPerPage: 3, + }, + url: '/', + version: null, + clearHistory: false, + encryptHistory: false, + rescuedProps: [], + rememberedState: {}, + flash: {}, + }, + resolve: () => Preview, + render: renderToString, + setup: ({ App, props }) => , + }); + + assert.ok(result); + return result.body; +} + +test('keeps recipient choices available when replying to your own commit with one other participant', async () => { + const html = await renderReplyComposer({ participant: 'other-user' }); + + assert.match(text(html), /Replying to @participant/); + assert.match(html, /placeholder="Post your reply"/); + assert.doesNotMatch(text(html), /Replying to @author/); +}); + +test('keeps the normal placeholder when replying to your own commit without other participants', async () => { + const html = await renderReplyComposer(null); + + assert.doesNotMatch(text(html), /Replying to/); + assert.match(html, /placeholder="Reply to @author"/); +}); + for (const [count, repliesPerPage] of [ [0, 3], [1, 3], diff --git a/tests/Unit/DraftStash.test.tsx b/tests/Unit/DraftStash.test.tsx index 2a332f8ec..39ca48ab3 100644 --- a/tests/Unit/DraftStash.test.tsx +++ b/tests/Unit/DraftStash.test.tsx @@ -342,3 +342,62 @@ test('drafts release image URLs on replacement, drop and logout while keeping re revoke.mockRestore(); } }); + +test('reply recipient exclusions survive closing and reloading a draft and clear on drop or logout', async () => { + vi.resetModules(); + vi.doMock('react', mockReactHooks); + let hooks = await import('../../resources/js/hooks/use-stash'); + vi.stubGlobal('window', { addEventListener() {} }); + vi.useFakeTimers(); + state = []; + const render = (user = 'recipient-author', name = 'reply-parent') => { + cursor = 0; + return hooks.useStash(user, name); + }; + + try { + let draft = render(); + assert.deepEqual(draft.excludedRecipientIds, []); + draft.updateExcludedRecipientIds(['excluded-developer']); + draft.updateBody('My unfinished reply'); + state = []; + draft = render(); + assert.equal(draft.body, 'My unfinished reply'); + assert.deepEqual(draft.excludedRecipientIds, ['excluded-developer']); + vi.advanceTimersByTime(300); + + vi.resetModules(); + hooks = await import('../../resources/js/hooks/use-stash'); + state = []; + draft = render(); + assert.equal(draft.body, 'My unfinished reply'); + assert.deepEqual(draft.excludedRecipientIds, ['excluded-developer']); + state = []; + assert.deepEqual(render('another-author').excludedRecipientIds, []); + state = []; + assert.deepEqual( + render('recipient-author', 'reply-other').excludedRecipientIds, + [], + ); + state = []; + draft = render(); + draft.drop(); + state = []; + draft = render(); + assert.equal(draft.body, ''); + assert.deepEqual(draft.excludedRecipientIds, []); + draft.updateExcludedRecipientIds(['excluded-developer']); + draft.updateImages([image]); + state = []; + draft = render(); + assert.deepEqual(draft.images, [image]); + assert.deepEqual(draft.excludedRecipientIds, ['excluded-developer']); + hooks.clearStashes('recipient-author'); + state = []; + assert.deepEqual(render().excludedRecipientIds, []); + } finally { + hooks.clearStashes('recipient-author'); + vi.useRealTimers(); + vi.unstubAllGlobals(); + } +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 2c64c4ad6..66a9e1542 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -7,6 +7,8 @@ "include": [ "Unit/**/*.test.ts", "Unit/**/*.test.tsx", + "Browser/**/*.ts", + "Browser/**/*.tsx", "../resources/js/types/*.d.ts", "draft-images.config.ts" ]