From f9a19b4d7cd89b4ffeb528a571205bd52dd38a61 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 7 Nov 2026 13:03:36 -0500 Subject: [PATCH 1/2] Report a Remotes message or a person to devnull's moderators Report message ends the long-press menu of someone else's message, and Report @handle joins Mute and Block in a profile's menu. Both open a report sheet like Report commit's: a reason, optional details, and Send report, with why devnull said no shown in the sheet. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Client.php | 28 ++++- app/Devnull/MessageMenu.php | 12 +- .../Concerns/ReportsToModerators.php | 107 ++++++++++++++++++ app/NativeComponents/Conversation.php | 28 ++++- app/NativeComponents/Profile.php | 24 +++- resources/views/native/conversation.blade.php | 1 + .../native/partials/report-sheet.blade.php | 58 ++++++++++ resources/views/native/profile.blade.php | 1 + tests/Feature/Devnull/ClientTest.php | 18 +++ tests/Feature/Screens/ConversationTest.php | 55 ++++++++- tests/Feature/Screens/ProfileSafetyTest.php | 36 ++++-- tests/Feature/Screens/ProfileShareTest.php | 2 +- .../Feature/Screens/RemotesVoiceNotesTest.php | 1 + 13 files changed, 347 insertions(+), 24 deletions(-) create mode 100644 app/NativeComponents/Concerns/ReportsToModerators.php create mode 100644 resources/views/native/partials/report-sheet.blade.php diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index b6538ae5..ab86c04e 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -547,9 +547,33 @@ public function olderMessages(string $threadPath, int $before): array */ public function reportCommit(string $id, ReportReason $reason, string $details = ''): void { - $payload = $details === '' ? ['reason' => $reason->value] : ['reason' => $reason->value, 'details' => $details]; + $this->send('POST', 'commits/'.rawurlencode($id).'/reports', payload: self::reportPayload($reason, $details)); + } + + /** + * Reports a Remotes message to devnull's moderators, privately. The + * message stays in the thread. Reporting one again keeps the first report. + */ + public function reportMessage(int $id, ReportReason $reason, string $details = ''): void + { + $this->send('POST', "remotes/messages/{$id}/reports", payload: self::reportPayload($reason, $details)); + } + + /** + * Reports someone to devnull's moderators, privately. They aren't told. + * Reporting them again keeps the first report. + */ + public function reportUser(string $username, ReportReason $reason, string $details = ''): void + { + $this->send('POST', 'users/'.rawurlencode($username).'/reports', payload: self::reportPayload($reason, $details)); + } - $this->send('POST', 'commits/'.rawurlencode($id).'/reports', payload: $payload); + /** + * @return array{reason: string, details?: string} + */ + private static function reportPayload(ReportReason $reason, string $details): array + { + return $details === '' ? ['reason' => $reason->value] : ['reason' => $reason->value, 'details' => $details]; } public function profile(string $username): Profile diff --git a/app/Devnull/MessageMenu.php b/app/Devnull/MessageMenu.php index 900b291d..c392d757 100644 --- a/app/Devnull/MessageMenu.php +++ b/app/Devnull/MessageMenu.php @@ -21,11 +21,12 @@ final class MessageMenu /** * $answerable is whether the message takes Reply and React, and $pinned whether it's pinned, or null when you * can't pin it. A message's photos open the same menu as its bubble, so only one of the two names its items - * ($named), and tests find them. + * ($named), and tests find them. $reportable is whether it's someone else's message devnull has, which ends the + * menu with Report message, in red. * * @return list */ - public static function items(Message $message, bool $answerable, ?bool $pinned, bool $named = true): array + public static function items(Message $message, bool $answerable, ?bool $pinned, bool $named = true, bool $reportable = false): array { $key = $message->id > 0 ? $message->id : $message->clientId; $argument = $message->id > 0 ? (string) $message->id : "'{$message->clientId}'"; @@ -46,15 +47,20 @@ public static function items(Message $message, bool $answerable, ?bool $pinned, : self::item('pin', 'Pin message', Lucide::Pin, "togglePin({$argument})", $key, $named); } + if ($reportable && $message->id > 0) { + $items[] = self::item('report', 'Report message', Lucide::Flag, "reportMessage({$message->id})", $key, $named, destructive: true); + } + return $items; } - private static function item(string $action, string $label, Lucide $icon, string $press, int|string $key, bool $named): TopBarAction + private static function item(string $action, string $label, Lucide $icon, string $press, int|string $key, bool $named, bool $destructive = false): TopBarAction { $item = NavAction::make($action) ->label($label) ->icon(ios: $icon->ios(), android: $icon->android()) ->press($press) + ->destructive($destructive) ->toElement(); return $named ? $item->ref("{$action}-menu-{$key}") : $item; diff --git a/app/NativeComponents/Concerns/ReportsToModerators.php b/app/NativeComponents/Concerns/ReportsToModerators.php new file mode 100644 index 00000000..8fcb444e --- /dev/null +++ b/app/NativeComponents/Concerns/ReportsToModerators.php @@ -0,0 +1,107 @@ +openReport('message', (string) $id, $preview); + } + + /** + * Opens the sheet for someone else's profile. + */ + protected function openUserReport(string $username): void + { + $this->openReport('user', $username, '@'.$username); + } + + public function sendReporting(): void + { + $reason = ReportReason::tryFrom($this->reportingReason); + + if (! $this->reportingOpen || $this->reportingSent || $reason === null) { + return; + } + + $kind = $this->reportingKind; + $id = $this->reportingId; + $details = trim($this->reportingDetails); + $screenError = $this->error; + + $sent = $this->attempt(function (Client $devnull) use ($kind, $id, $reason, $details): bool { + $kind === 'message' + ? $devnull->reportMessage((int) $id, $reason, $details) + : $devnull->reportUser($id, $reason, $details); + + return true; + }); + + $this->reportingError = $sent === true ? null : $this->error; + $this->error = $screenError; + $this->reportingSent = $sent === true; + } + + /** + * Done, or the sheet dragged away. + */ + public function closeReporting(): void + { + $this->openReport('', '', ''); + $this->reportingOpen = false; + } + + private function openReport(string $kind, string $id, string $preview): void + { + $this->reportingKind = $kind; + $this->reportingId = $id; + $this->reportingPreview = $preview; + $this->reportingReason = ''; + $this->reportingDetails = ''; + $this->reportingSent = false; + $this->reportingError = null; + $this->reportingOpen = true; + } +} diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 2302f411..48c272b5 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -28,6 +28,7 @@ use App\NativeComponents\Concerns\PlaysVoiceNotes; use App\NativeComponents\Concerns\RecordsVoiceNotes; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\ReportsToModerators; use App\NativeComponents\Concerns\SuggestsMentions; use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; @@ -107,6 +108,7 @@ class Conversation extends NativeComponent use PlaysVoiceNotes; use RecordsVoiceNotes; use RefreshesInBackground; + use ReportsToModerators; use SuggestsMentions; use SyncsOutbox; use TalksToDevnull; @@ -822,6 +824,20 @@ public function reply(int $id): void $this->focusField(onlyIfIdle: true); } + /** + * Report message, from the menu a long press opens on someone else's message: the report sheet, quoting it. + */ + public function reportMessage(int $id): void + { + $message = $this->findMessage($id); + + if ($message === null || ! $this->isReportable($message)) { + return; + } + + $this->openMessageReport($id, $message->body !== '' ? $message->body : ($message->voice !== null ? 'Voice note' : 'Photo')); + } + /** * Copy text, from the menu a long press opens: the message's text, as it was written, on the clipboard. A message * still being pushed is named by its client id. iOS says so in a toast, in the web's words. Android says so itself, @@ -1625,6 +1641,14 @@ private function canPin(): bool /** * React, Reply and Pin are for messages devnull has, not an org's events, while the thread takes messages. */ + /** + * Someone else's message, sent, which devnull's moderators can be told about. + */ + private function isReportable(Message $message): bool + { + return ! $message->isEvent() && ! $message->isPending() && $message->id > 0 && $message->userId !== $this->me()?->id; + } + private function takesActions(Message $message): bool { return ! $message->isEvent() && ! $message->isPending() && $this->thread?->access === Thread::Open; @@ -1913,8 +1937,8 @@ private function items(?User $viewer): array 'author' => $message->user ?? $this->authorOf($message->userId), 'pan' => $this->panFor($message), 'slide' => $this->slideOf($message), - 'menu' => MessageMenu::items($message, $answerable, $pinned), - 'photosMenu' => $message->images === [] || $message->body === '' ? [] : MessageMenu::items($message, $answerable, $pinned, named: false), + 'menu' => MessageMenu::items($message, $answerable, $pinned, reportable: $this->isReportable($message)), + 'photosMenu' => $message->images === [] || $message->body === '' ? [] : MessageMenu::items($message, $answerable, $pinned, named: false, reportable: $this->isReportable($message)), ]; } diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index d4c1e690..98d871e5 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -23,6 +23,7 @@ use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\OpensLiveStream; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\ReportsToModerators; use App\NativeComponents\Concerns\ShowsCommits; use App\NativeComponents\Concerns\TalksToDevnull; use App\NativeComponents\Concerns\WatchesForCalls; @@ -77,6 +78,7 @@ class Profile extends NativeComponent } use OpensLiveStream; use RefreshesInBackground; + use ReportsToModerators; use ShowsCommits; use TalksToDevnull; use WatchesForCalls; @@ -228,9 +230,9 @@ private function shareAction(): NavAction } /** - * The web's safety menu: Mute or Unmute, then Block, in red. A profile you - * blocked offers only Unblock, as the web's blocked page does, and one that - * blocked you offers nothing. It waits for devnull to say whether you mute + * The web's safety menu: Mute or Unmute, then Block and Report, in red. A + * profile you blocked offers Unblock and Report, and one that blocked you + * offers nothing. It waits for devnull to say whether you mute * them. * * @return list @@ -239,8 +241,10 @@ private function safetyActions(): array { $handle = '@'.$this->username; + $report = NavAction::make('report')->label("Report {$handle}")->icon(ios: Lucide::Flag->ios(), android: Lucide::Flag->android())->destructive()->press('reportUser'); + if ($this->isBlocking()) { - return [NavAction::make('unblock')->label("Unblock {$handle}")->icon(ios: Lucide::Ban->ios(), android: Lucide::Ban->android())->press('confirmBlock')]; + return [NavAction::make('unblock')->label("Unblock {$handle}")->icon(ios: Lucide::Ban->ios(), android: Lucide::Ban->android())->press('confirmBlock'), $report]; } if (! $this->canChangeSafety()) { @@ -253,9 +257,21 @@ private function safetyActions(): array return [ NavAction::make($muting ? 'unmute' : 'mute')->label(($muting ? 'Unmute ' : 'Mute ').$handle)->icon(ios: $muteIcon->ios(), android: $muteIcon->android())->press('confirmMute'), NavAction::make('block')->label("Block {$handle}")->icon(ios: Lucide::Ban->ios(), android: Lucide::Ban->android())->destructive()->press('confirmBlock'), + $report, ]; } + /** + * Report, from the safety menu: the report sheet, for someone you could + * mute or block, or have blocked. + */ + public function reportUser(): void + { + if ($this->canChangeSafety() || $this->isBlocking()) { + $this->openUserReport($this->username); + } + } + /** * Pull to refresh. You're at the top, so new commits go straight in. */ diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php index c01b14b7..38f70be4 100644 --- a/resources/views/native/conversation.blade.php +++ b/resources/views/native/conversation.blade.php @@ -7,6 +7,7 @@ @include('native.partials.error') @include('native.partials.refresh-failed') + @include('native.partials.report-sheet') {{-- Each row is a child of the scroll view itself, not of one column inside it: SwiftUI keeps a reader's place by diff --git a/resources/views/native/partials/report-sheet.blade.php b/resources/views/native/partials/report-sheet.blade.php new file mode 100644 index 00000000..64416354 --- /dev/null +++ b/resources/views/native/partials/report-sheet.blade.php @@ -0,0 +1,58 @@ +{{-- + The sheet that reports a Remotes message or a person (see ReportsToModerators), as the commit sheet's Report commit + form: the title, a not-hidden line, what's reported, the four reasons, optional details, and Send report, which waits + for a reason. Once devnull has the report, the sheet says so, with Done. If devnull says no, why shows above Send + report. +--}} + + @if ($reportingSent) + + + + Report received. Moderators will review it privately. + + Done + + @elseif ($reportingKind !== '') + + + + {{ $reportingKind === 'message' ? 'Report message' : 'Report '.$reportingPreview }} + {{ $reportingKind === 'message' ? 'Your report is private. Only moderators can review it, and they will see this message. Reporting does not hide it.' : 'Your report is private. Only moderators can review it. They will not be told. To stop seeing them, block them too.' }} + + + @if ($reportingKind === 'message') + + + {{ $reportingPreview }} + + + @endif + + + @foreach (\App\Devnull\ReportReason::cases() as $reportOption) + + @endforeach + + + + + @if ($reportingError) + {{ $reportingError }} + @endif + + Send report + + + @endif + diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index efd5934b..8a0b8e0c 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -2,6 +2,7 @@ @include('native.partials.error') @include('native.partials.refresh-failed') @include('native.partials.commit-sheet') + @include('native.partials.report-sheet') @include('native.partials.achievements-sheet') @include('native.partials.live-stream-sheet') @include('native.partials.stargazers-sheet') diff --git a/tests/Feature/Devnull/ClientTest.php b/tests/Feature/Devnull/ClientTest.php index 67c0af76..91f59a6d 100644 --- a/tests/Feature/Devnull/ClientTest.php +++ b/tests/Feature/Devnull/ClientTest.php @@ -726,3 +726,21 @@ function receivedMessage(Request $request): string 'a code from a newer server' => ['something_new'], 'no code' => [null], ]); + +it('reports a Remotes message and a person with signed POSTs of the reason, and the details when there are some', function (Closure $report, string $path, string $body) { + Http::fake(['devnull.test/*' => Http::response(null, 204)]); + + $report(app(Client::class)); + + Http::assertSent(function (Request $request) use ($path, $body): bool { + expect($request->method())->toBe('POST') + ->and($request->url())->toBe('http://devnull.test/api/v1/'.$path) + ->and($request->body())->toBe($body) + ->and(receivedMessage($request))->toEndWith("\nPOST\n/api/v1/{$path}\n".$body); + + return true; + }); +})->with([ + 'a message' => [fn (Client $devnull) => $devnull->reportMessage(42, ReportReason::Harassment, 'Threats.'), 'remotes/messages/42/reports', '{"reason":"harassment","details":"Threats."}'], + 'a person' => [fn (Client $devnull) => $devnull->reportUser('ada', ReportReason::Spam), 'users/ada/reports', '{"reason":"spam"}'], +]); diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 14d6bc75..491a3544 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -1107,7 +1107,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul ->assertNavigatedTo('/remotes/commits/'.$commit); }); -it('opens Reply, React and Copy text on a long press on a message, yours or theirs, and keeps its tap and double tap', function () { +it('opens Reply, React and Copy text on a long press on a message, yours or theirs, then Report message on theirs, and keeps its tap and double tap', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), @@ -1120,6 +1120,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul ['reply-menu-1', 'Reply', 'custom:reply'], ['react-menu-1', 'React', 'custom:smile-plus'], ['copy-menu-1', 'Copy text', 'custom:copy'], + ['report-menu-1', 'Report message', 'custom:flag'], ]) ->and(array_column(menuItems($screen, 'bubble-2'), 0))->toBe(['reply-menu-2', 'react-menu-2', 'copy-menu-2']) ->and(nodeByRef($screen, 'bubble-1')['props']['on_double_tap'] ?? 0)->toBeGreaterThan(0); @@ -1138,13 +1139,15 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul expect(menuItems($screen, 'photos-1'))->toBe([ ['reply-menu-1', 'Reply', 'custom:reply'], ['react-menu-1', 'React', 'custom:smile-plus'], + ['report-menu-1', 'Report message', 'custom:flag'], ]) ->and(menuItems($screen, 'photos-2'))->toBe([ [null, 'Reply', 'custom:reply'], [null, 'React', 'custom:smile-plus'], [null, 'Copy text', 'custom:copy'], + [null, 'Report message', 'custom:flag'], ]) - ->and(array_column(menuItems($screen, 'bubble-2'), 0))->toBe(['reply-menu-2', 'react-menu-2', 'copy-menu-2']); + ->and(array_column(menuItems($screen, 'bubble-2'), 0))->toBe(['reply-menu-2', 'react-menu-2', 'copy-menu-2', 'report-menu-2']); }); it('only copies the text of a message still being pushed, or in a thread that cannot take a message', function () { @@ -1162,7 +1165,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?'])], ['read_through_id' => 1, 'access' => 'request'])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); - expect(array_column(menuItems($screen, 'bubble-1'), 1))->toBe(['Copy text']); + expect(array_column(menuItems($screen, 'bubble-1'), 1))->toBe(['Copy text', 'Report message']); }); it('copies the text of a message as it was written, and says so on iOS, where Android says so itself', function (string $platform, array $toasts) { @@ -1218,7 +1221,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul serveOrgThread(orgThreadJson([orgMessageJson(['id' => 1, 'body' => 'standup in 10?'])], ['role' => 'contributor'])); $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); - expect(array_column(menuItems($screen, 'bubble-1'), 1))->toBe(['Reply', 'React', 'Copy text']); + expect(array_column(menuItems($screen, 'bubble-1'), 1))->toBe(['Reply', 'React', 'Copy text', 'Report message']); }); /** @@ -1363,3 +1366,47 @@ function orgWithMembers(array $people): array ->tap('mention-ada') ->assertSet('draft', 'hi'); }); + +it("reports someone else's message to devnull's moderators from its menu, quoting it in the sheet", function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'send me your keys']), + remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'no']), + ], ['read_through_id' => 2])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada', platform: 'ios'))->tap('report-menu-1'); + + expect(nodeByRef($screen, 'reporting-body')['props']['text'])->toBe('send me your keys') + ->and(nodeByRef($screen, 'send-reporting')['props']['disabled'] ?? false)->toBeTrue(); + + $screen->selectRadio('reporting-reason', 'harassment') + ->input('reporting-details', 'Phishing.') + ->tap('send-reporting') + ->assertSee('Report received. Moderators will review it privately.'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'http://devnull.test/api/v1/remotes/messages/1/reports' + && $request->body() === '{"reason":"harassment","details":"Phishing."}'); + + // Your own message can't be reported, even when its menu is pressed. + $screen->call('closeReporting')->call('reportMessage', 2); + + expect($screen->instance()->reportingOpen)->toBeFalse(); +}); + +it("shows why devnull didn't take a message's report in the sheet, keeping the form", function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => 'spam spam'])], ['read_through_id' => 1])); + serveApi(fn (Request $request) => $request->method() === 'POST' && str_ends_with($request->url(), '/reports') + ? Http::response(['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429) + : Http::response(threadJson([remoteMessageJson(['id' => 1, 'body' => 'spam spam'])], ['read_through_id' => 1]))); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada', platform: 'ios')) + ->tap('report-menu-1') + ->selectRadio('reporting-reason', 'spam') + ->tap('send-reporting'); + + expect(nodeByRef($screen, 'reporting-error')['props']['text'])->toBe('Too Many Attempts.') + ->and(nodeByRef($screen, 'reporting-reason')['props']['value'])->toBe('spam') + ->and($screen->instance()->error)->toBeNull(); + + $screen->assertDontSee('Report received'); +}); diff --git a/tests/Feature/Screens/ProfileSafetyTest.php b/tests/Feature/Screens/ProfileSafetyTest.php index 9dec539c..33a14d95 100644 --- a/tests/Feature/Screens/ProfileSafetyTest.php +++ b/tests/Feature/Screens/ProfileSafetyTest.php @@ -90,7 +90,7 @@ function safetySent(string $method, string $path): bool return collect(Http::recorded())->contains(fn (array $sent): bool => $sent[0]->method() === $method && $sent[0]->url() === 'http://devnull.test/api/v1/'.$path); } -it("offers Mute and Block from a ⋯ in the nav bar of someone else's profile, as the web's menu does", function () { +it("offers Mute, Block and Report from a ⋯ in the nav bar of someone else's profile, as the web's menu does", function () { fakeApi(['GET /api/v1/users/ada' => [safetyProfileJson()], 'GET /api/v1/users/ada/commits' => [pageJson([])]]); $screen = Native::visit('/log/users/ada', platform: 'ios'); @@ -100,6 +100,7 @@ function safetySent(string $method, string $path): bool ->and(safetyMenu($screen))->toBe([ 'mute' => ['id' => 'mute', 'icon' => 'custom:volume-x', 'label' => 'Mute @ada'], 'block' => ['id' => 'block', 'icon' => 'custom:ban', 'label' => 'Block @ada', 'destructive' => true], + 'report' => ['id' => 'report', 'icon' => 'custom:flag', 'label' => 'Report @ada', 'destructive' => true], ]); }); @@ -153,7 +154,7 @@ function safetySent(string $method, string $path): bool expect(safetySent('POST', 'users/ada/mute'))->toBeTrue() ->and($screen->instance()->profile->isMuting)->toBeTrue() ->and(kept('users/ada')['is_muting'])->toBeTrue() - ->and(array_keys(safetyMenu($screen)))->toBe(['unmute', 'block']); + ->and(array_keys(safetyMenu($screen)))->toBe(['unmute', 'block', 'report']); }); it('unmutes once you confirm', function () { @@ -171,7 +172,7 @@ function safetySent(string $method, string $path): bool ->assertMissingElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'muted'); expect(safetySent('DELETE', 'users/ada/mute'))->toBeTrue() - ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block']); + ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block', 'report']); }); it('puts the mute back, and says why, when devnull says no', function () { @@ -187,7 +188,7 @@ function safetySent(string $method, string $path): bool ->assertMissingElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'muted'); expect($screen->instance()->profile->isMuting)->toBeFalse() - ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block']); + ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block', 'report']); }); it("blocks once you confirm with the web's Block dialog, and shows the blocked page", function () { @@ -219,9 +220,10 @@ function safetySent(string $method, string $path): bool expect(safetySent('POST', 'users/ada/block'))->toBeTrue() ->and(kept('users/ada'))->toBeNull() ->and(kept('users/ada/commits'))->toBeNull() - // A blocked profile offers only Unblock, as the web's blocked page does. + // A blocked profile offers Unblock, as the web's blocked page does, and Report. ->and(safetyMenu($screen))->toBe([ 'unblock' => ['id' => 'unblock', 'icon' => 'custom:ban', 'label' => 'Unblock @ada'], + 'report' => ['id' => 'report', 'icon' => 'custom:flag', 'label' => 'Report @ada', 'destructive' => true], ]); }); @@ -239,7 +241,7 @@ function safetySent(string $method, string $path): bool ->assertSee('Notes on the engine') ->assertDontSee('is blocked'); - expect(array_keys(safetyMenu($screen)))->toBe(['mute', 'block']); + expect(array_keys(safetyMenu($screen)))->toBe(['mute', 'block', 'report']); }); it('shows who you blocked as devnull sends them, with nothing kept', function () { @@ -250,7 +252,7 @@ function safetySent(string $method, string $path): bool ->assertSee("You blocked Ada Lovelace. Neither of you can follow, reply to, or star the other, or see each other's commits.") ->assertElement('image', fn (array $node): bool => ($node['props']['src'] ?? $node['props']['url'] ?? null) === safetyAdaJson()['avatar_url']); - expect(array_keys(safetyMenu($screen)))->toBe(['unblock']); + expect(array_keys(safetyMenu($screen)))->toBe(['unblock', 'report']); }); it("unblocks from the menu once you confirm, in the web's words, and shows the profile again without the follows", function () { @@ -285,7 +287,7 @@ function safetySent(string $method, string $path): bool ->assertSee('Notes on the engine'); expect(safetySent('DELETE', 'users/ada/block'))->toBeTrue() - ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block']); + ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block', 'report']); }); it("unblocks straight away from the blocked page's own button, as the web's does, and stays blocked when devnull says no", function () { @@ -354,3 +356,21 @@ function safetySent(string $method, string $path): bool ->assertDontSee('Ada on Trending') ->assertSee('Someone else on Trending'); }); + +it("reports someone to devnull's moderators from their profile's menu, without blocking them", function () { + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson()], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + 'POST /api/v1/users/ada/reports' => [null, 204], + ]); + + Native::visit('/log/users/ada', platform: 'ios') + ->press('reportUser') + ->assertSee('Report @ada') + ->selectRadio('reporting-reason', 'spam') + ->tap('send-reporting') + ->assertSee('Report received. Moderators will review it privately.'); + + expect(safetySent('POST', 'users/ada/reports'))->toBeTrue() + ->and(safetySent('POST', 'users/ada/block'))->toBeFalse(); +}); diff --git a/tests/Feature/Screens/ProfileShareTest.php b/tests/Feature/Screens/ProfileShareTest.php index b0e08243..81287acd 100644 --- a/tests/Feature/Screens/ProfileShareTest.php +++ b/tests/Feature/Screens/ProfileShareTest.php @@ -44,7 +44,7 @@ function shareMenuIds(TestableComponent $screen): ?array $screen = Native::visit('/log/users/ada', platform: $platform); $share = findNode($screen->tree(), fn (array $node): bool => ($node['props']['id'] ?? null) === 'share'); - expect(shareMenuIds($screen))->toBe(['share', 'mute', 'block']) + expect(shareMenuIds($screen))->toBe(['share', 'mute', 'block', 'report']) ->and($share['props'])->toMatchArray(['label' => 'Share profile', 'icon' => $icon]); })->with([ 'iOS' => ['ios', 'custom:share'], diff --git a/tests/Feature/Screens/RemotesVoiceNotesTest.php b/tests/Feature/Screens/RemotesVoiceNotesTest.php index 7dee858e..ef2058c4 100644 --- a/tests/Feature/Screens/RemotesVoiceNotesTest.php +++ b/tests/Feature/Screens/RemotesVoiceNotesTest.php @@ -505,6 +505,7 @@ function playedBars(TestableComponent $screen, string $ref): int ->and(menuItems($screen, 'bubble-2'))->toBe([ ['reply-menu-2', 'Reply', 'custom:reply'], ['react-menu-2', 'React', 'custom:smile-plus'], + ['report-menu-2', 'Report message', 'custom:flag'], ]); $screen->tap('reply-menu-2')->assertSee('Voice note'); From 90151e4efe0d996137d36c79108210ac4614bb72 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 7 Nov 2026 13:04:49 -0500 Subject: [PATCH 2/2] Keep the report sheet's contents while it slides away Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/Concerns/ReportsToModerators.php | 5 +++-- app/NativeComponents/Conversation.php | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/NativeComponents/Concerns/ReportsToModerators.php b/app/NativeComponents/Concerns/ReportsToModerators.php index 8fcb444e..1accede0 100644 --- a/app/NativeComponents/Concerns/ReportsToModerators.php +++ b/app/NativeComponents/Concerns/ReportsToModerators.php @@ -85,11 +85,12 @@ public function sendReporting(): void } /** - * Done, or the sheet dragged away. + * Done, or the sheet dragged away. What it shows stays until the next + * report opens, so it doesn't empty while it slides away, and iOS's late + * dismiss after Done changes nothing. */ public function closeReporting(): void { - $this->openReport('', '', ''); $this->reportingOpen = false; } diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 48c272b5..4d0d463e 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -1638,17 +1638,19 @@ private function canPin(): bool return $this->isOrg() && $this->thread?->role === 'maintainer' && $this->thread->access === Thread::Open; } - /** - * React, Reply and Pin are for messages devnull has, not an org's events, while the thread takes messages. - */ /** * Someone else's message, sent, which devnull's moderators can be told about. */ private function isReportable(Message $message): bool { - return ! $message->isEvent() && ! $message->isPending() && $message->id > 0 && $message->userId !== $this->me()?->id; + $me = $this->me(); + + return $me !== null && ! $message->isEvent() && ! $message->isPending() && $message->id > 0 && $message->userId !== $me->id; } + /** + * React, Reply and Pin are for messages devnull has, not an org's events, while the thread takes messages. + */ private function takesActions(Message $message): bool { return ! $message->isEvent() && ! $message->isPending() && $this->thread?->access === Thread::Open;