From 61620aaeda49ba570360c5ece4112df7658f7754 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 16:26:23 +0100 Subject: [PATCH] Mute and block people, and undo it from your account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone else's profile now has the web's safety menu behind a ⋯ in the nav bar: Mute or Unmute, and Block in red. Each asks first in the web's words, then changes straight away and goes back if devnull says no. Blocking turns the profile into the blocked page, which now has Unblock as on the web, and devnull sends who you blocked with it, so the page has their name and avatar even with nothing kept. A new Muted and blocked screen lists both groups as the web's Safety settings do, with Unmute and Unblock. It opens from a Safety section on the Account screen, and from a blocked profile instead of the web. Mutes and blocks patch the kept answers they change. Their commits leave the kept logs, and the log takes out what it still shows of them when you come back, Trending included. NativePHP gives a screen one listener per event, so Profile's alert handler passes the delete alert on to ShowsCommits, and deleting your own commit from your profile still works. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Client.php | 59 +++ app/Devnull/Data/Profile.php | 5 + app/Devnull/Exceptions/ApiException.php | 6 +- app/Devnull/ResponseCache.php | 177 +++++++++ app/Icons/Lucide.php | 6 + .../Concerns/ShowsCommits.php | 10 + app/NativeComponents/Log.php | 19 + app/NativeComponents/Profile.php | 290 ++++++++++++++- app/NativeComponents/Safety.php | 196 ++++++++++ app/NativeComponents/ThisDevice.php | 11 +- resources/icons/ban.svg | 1 + resources/icons/volume-2.svg | 1 + resources/icons/volume-x.svg | 1 + .../partials/profile-unavailable.blade.php | 11 +- resources/views/native/safety.blade.php | 68 ++++ resources/views/native/this-device.blade.php | 11 + routes/mobile.php | 7 +- tests/Feature/Devnull/ClientCacheTest.php | 112 ++++++ tests/Feature/Screens/ProfileSafetyTest.php | 344 ++++++++++++++++++ tests/Feature/Screens/ProfileStandingTest.php | 2 +- tests/Feature/Screens/SafetyTest.php | 264 ++++++++++++++ 21 files changed, 1577 insertions(+), 24 deletions(-) create mode 100644 app/NativeComponents/Safety.php create mode 100644 resources/icons/ban.svg create mode 100644 resources/icons/volume-2.svg create mode 100644 resources/icons/volume-x.svg create mode 100644 resources/views/native/safety.blade.php create mode 100644 tests/Feature/Screens/ProfileSafetyTest.php create mode 100644 tests/Feature/Screens/SafetyTest.php diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index e4b5cc5..d9f0566 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -53,6 +53,14 @@ */ public const string LanePath = 'me/lane'; + /** + * The people the signed-in account mutes, and blocks, as the web's + * Settings → Safety lists them. + */ + public const string MutedPath = 'me/muted'; + + public const string BlockedPath = 'me/blocked'; + public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} /** @@ -252,6 +260,57 @@ public function unfollow(string $username): void $this->cache->followChanged(rawurlencode($username), false); } + /** + * Mutes someone, as the web's Mute does. It's silent: they aren't told, + * and the follows between you stay. Their commits leave your logs. + */ + public function mute(string $username): void + { + $this->cache->muteChanged($this->json($this->send('POST', 'users/'.rawurlencode($username).'/mute'))); + } + + public function unmute(string $username): void + { + $this->cache->muteChanged($this->json($this->send('DELETE', 'users/'.rawurlencode($username).'/mute'))); + } + + /** + * Blocks someone, as the web's Block does, which ends the follows both + * ways. Neither of you sees the other's profile or commits after it. + */ + public function block(string $username): void + { + $this->cache->blockChanged($this->json($this->send('POST', 'users/'.rawurlencode($username).'/block'))); + } + + /** + * Unblocking doesn't bring back the follows, as on the web. + */ + public function unblock(string $username): void + { + $this->cache->blockChanged($this->json($this->send('DELETE', 'users/'.rawurlencode($username).'/block'))); + } + + /** + * The people you mute, last muted first. + * + * @return Page + */ + public function muted(?string $cursor = null): Page + { + return Page::fromArray($this->get(self::MutedPath, ['cursor' => $cursor]), User::fromArray(...)); + } + + /** + * The people you block, last blocked first. + * + * @return Page + */ + public function blocked(?string $cursor = null): Page + { + return Page::fromArray($this->get(self::BlockedPath, ['cursor' => $cursor]), User::fromArray(...)); + } + /** * Your notifications grouped as the web groups them, filtered by the web's * ?type= (all, replies, quotes, stars, mentions, follows or moderation). diff --git a/app/Devnull/Data/Profile.php b/app/Devnull/Data/Profile.php index 21eba5e..d851eef 100644 --- a/app/Devnull/Data/Profile.php +++ b/app/Devnull/Data/Profile.php @@ -53,6 +53,11 @@ public function withFollowing(bool $isFollowing): self return new self(...[...get_object_vars($this), 'followersCount' => max(0, $this->followersCount + $change), 'isFollowing' => $isFollowing]); } + public function withMuting(bool $isMuting): self + { + return new self(...[...get_object_vars($this), 'isMuting' => $isMuting]); + } + public function withPinnedCommit(?Commit $pinnedCommit): self { return new self(...[...get_object_vars($this), 'pinnedCommit' => $pinnedCommit]); diff --git a/app/Devnull/Exceptions/ApiException.php b/app/Devnull/Exceptions/ApiException.php index aca5483..d969a1a 100644 --- a/app/Devnull/Exceptions/ApiException.php +++ b/app/Devnull/Exceptions/ApiException.php @@ -2,6 +2,7 @@ namespace App\Devnull\Exceptions; +use App\Devnull\Data\User; use App\Devnull\ErrorCode; use Carbon\CarbonImmutable; use Illuminate\Http\Client\Response; @@ -19,8 +20,9 @@ class ApiException extends RuntimeException * @param array> $errors * @param int|null $retryAfter How many seconds the server asked to wait before trying again. * @param string|null $errorCode The machine-readable code devnull sends next to the message, such as "device_not_approved". + * @param User|null $user The user devnull sends with the error, as it sends the one you blocked with user_blocked. */ - public function __construct(string $message, public readonly int $status = 0, public readonly array $errors = [], public readonly ?int $retryAfter = null, public readonly ?string $errorCode = null) + public function __construct(string $message, public readonly int $status = 0, public readonly array $errors = [], public readonly ?int $retryAfter = null, public readonly ?string $errorCode = null, public readonly ?User $user = null) { parent::__construct($message, $status); } @@ -51,7 +53,7 @@ public static function fromParts(int $status, mixed $json, string $retryAfter = return match ($status) { 401 => new Unauthenticated($message, 401, errorCode: $code), 422 => new ApiException($message, 422, $errors, errorCode: $code), - default => new ApiException($message, $status, retryAfter: self::retryAfter($retryAfter), errorCode: $code), + default => new ApiException($message, $status, retryAfter: self::retryAfter($retryAfter), errorCode: $code, user: is_array($json['user'] ?? null) ? User::fromArray($json['user']) : null), }; } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index fa3d59f..6e8ac8e 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -35,6 +35,8 @@ final class ResponseCache */ private const array Cacheable = [ '#^me$#', + '#^me/muted$#', + '#^me/blocked$#', '#^notifications$#', '#^notifications/[^/]+/group$#', '#^log/[^/]+$#', @@ -81,6 +83,11 @@ final class ResponseCache */ private const int RememberedUnsaves = 50; + /** + * How many of the people muted or blocked on this phone it remembers, until a log has taken their commits out. + */ + private const int RememberedHidden = 50; + /** * What every copy of a user has of their profile, as a commit's author, a * notification's actor or a profile's header. @@ -390,6 +397,131 @@ private function patchFollowedPeople(string $username, bool $following): void } } + /** + * You muted or unmuted someone, and devnull answered with them and + * whether you mute them now: their profile says so, and they join the top + * of the people you mute, or leave it. Once muted, their commits leave + * your logs, as devnull leaves them out. Unmuted, they come back with the + * next refresh. + * + * @param array $json + */ + public function muteChanged(array $json): void + { + [$user, $muting] = self::changedUser($json, 'is_muting'); + + if ($user === null) { + return; + } + + $this->patch('users/'.rawurlencode($user['username']), [], fn (array $kept): array => [...$kept, 'is_muting' => $muting]); + $this->patch(Client::MutedPath, [], fn (array $kept): array => self::listed($kept, $user, $muting)); + + $this->rememberHidden($user['id'], $muting); + + if ($muting) { + $this->dropFromLogs($user['id']); + } + } + + /** + * You blocked or unblocked someone, and devnull answered with them and + * whether you block them now: they join the top of the people you block, + * or leave it. Across a block neither of you sees the other's profile or + * commits, and the follows between you end, so their profile and its + * commits go, their commits leave your logs, and your own counts lose + * the follows. Unblocking doesn't bring the follows back. + * + * @param array $json + */ + public function blockChanged(array $json): void + { + [$user, $blocking] = self::changedUser($json, 'is_blocking'); + + if ($user === null) { + return; + } + + $this->patch(Client::BlockedPath, [], fn (array $kept): array => self::listed($kept, $user, $blocking)); + $this->rememberHidden($user['id'], $blocking); + + if (! $blocking) { + return; + } + + $path = 'users/'.rawurlencode($user['username']); + $profile = $this->get($path); + + if ($profile !== null && ($me = $this->username()) !== null) { + $this->patch("users/{$me}", [], fn (array $kept): array => self::count( + self::count($kept, 'following_count', ($profile['is_following'] ?? false) === true ? -1 : 0), + 'followers_count', + ($profile['follows_you'] ?? false) === true ? -1 : 0, + )); + } + + $this->forget($path); + $this->forget("{$path}/commits"); + $this->forget("{$path}/commits", ['replies' => '1']); + $this->dropFromLogs($user['id']); + } + + /** + * The user a mute or block answered with, and the state devnull says it left. + * + * @param array $json + * @return array{0: array{id: string, username: string}|null, 1: bool} + */ + private static function changedUser(array $json, string $state): array + { + $user = is_array($json['data'] ?? null) ? $json['data'] : []; + + if (! is_string($user['id'] ?? null) || $user['id'] === '' || ! is_string($user['username'] ?? null) || $user['username'] === '') { + return [null, false]; + } + + return [$user, ($json[$state] ?? null) === true]; + } + + /** + * Takes someone's commits out of every kept log, and out of what an empty + * Following log suggests. + */ + private function dropFromLogs(string $authorId): void + { + $userId = $this->userId(); + + foreach ($userId === null ? [] : $this->index($userId) as $meta) { + if (! str_starts_with($meta['path'], 'log/')) { + continue; + } + + $this->patch($meta['path'], $meta['query'], function (array $json) use ($authorId): array { + foreach (['data', 'suggestions'] as $list) { + if (is_array($json[$list] ?? null)) { + $json[$list] = array_values(array_filter($json[$list], fn (mixed $commit): bool => ! is_array($commit) || ($commit['author']['id'] ?? null) !== $authorId)); + } + } + + return $json; + }); + } + } + + /** + * A kept list of people, with $user on top, or without them. + * + * @param array $json + * @param array{id: string} $user + * @return array + */ + private static function listed(array $json, array $user, bool $listed): array + { + $without = self::without($json['data'] ?? [], $user['id']); + + return [...$json, 'data' => $listed ? [$user, ...$without] : $without]; + } + /** * Commits deleted on this phone lately, for a ranked log, which can't tell * a commit that went from one that only fell down the ranking. @@ -404,6 +536,51 @@ public function deletedIds(): array return is_array($ids) ? array_values(array_filter($ids, is_string(...))) : []; } + /** + * The people muted or blocked on this phone since a log last asked, whose + * commits it may still be showing from before: Trending keeps what it + * shows in place, and pages further down aren't kept to patch. Asking + * forgets them, so a mute undone on the web hides nothing for long. + * + * @return list + */ + public function takeHiddenAuthorIds(): array + { + $userId = $this->userId(); + + if ($userId === null) { + return []; + } + + $ids = $this->hiddenAuthorIds($userId); + $this->store()->forget("{$userId} hidden"); + + return $ids; + } + + /** + * @return list + */ + private function hiddenAuthorIds(string $userId): array + { + $ids = $this->store()->get("{$userId} hidden"); + + return is_array($ids) ? array_values(array_filter($ids, is_string(...))) : []; + } + + private function rememberHidden(string $authorId, bool $hidden): void + { + $userId = $this->userId(); + + if ($userId === null) { + return; + } + + $others = array_values(array_diff($this->hiddenAuthorIds($userId), [$authorId])); + + $this->store()->forever("{$userId} hidden", array_slice($hidden ? [$authorId, ...$others] : $others, 0, self::RememberedHidden)); + } + private function rememberDeletion(string $id): void { $userId = $this->userId(); diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 653e2c4..83ec0df 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -52,6 +52,9 @@ enum Lucide case Flag; case RefreshCw; case LockKeyhole; + case VolumeX; + case Volume2; + case Ban; // The achievement badges, named as devnull names their icons (see forBadge()). case Terminal; @@ -130,6 +133,9 @@ public function custom(bool $filled = false): ?string self::Flag => 'flag', self::RefreshCw => 'refresh-cw', self::LockKeyhole => 'lock-keyhole', + self::VolumeX => 'volume-x', + self::Volume2 => 'volume-2', + self::Ban => 'ban', self::Terminal => 'terminal', self::FileCode => 'file-code', self::Flame => 'flame', diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index b24cdd2..e40efbc 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -179,6 +179,16 @@ public function confirmDelete(string $id): void #[On(ButtonPressed::class)] public function onAlertButton(string $label = '', ?string $id = null): void + { + $this->answerDeleteAlert($label, $id); + } + + /** + * Deletes the commit the delete alert asked about, once you pick Delete. A + * screen with alerts of its own answers them in its own onAlertButton(), + * and passes the delete alert on to this. + */ + protected function answerDeleteAlert(string $label, ?string $id): void { if ($id !== self::DeleteAlert || $this->deleting === null) { return; diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 864e558..c1b413a 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -170,6 +170,7 @@ public function onResume(): void $this->forgetRefreshes(); $this->closeEmptyComposer(); $this->forgetDeleted(); + $this->forgetHidden(); $this->showKept(); $this->revalidate(); } @@ -640,6 +641,24 @@ private function forgetDeleted(): void $this->latestCommits = array_values(array_filter($this->latestCommits, fn (Commit $commit): bool => ! isset($deleted[$commit->id]))); } + /** + * Takes out the commits of anyone you muted or blocked since, as devnull + * leaves them out. + */ + private function forgetHidden(): void + { + $hidden = array_flip(app(ResponseCache::class)->takeHiddenAuthorIds()); + + if ($hidden === []) { + return; + } + + $shown = fn (Commit $commit): bool => ! isset($hidden[$commit->author->id]); + + $this->commits = array_values(array_filter($this->commits, $shown)); + $this->latestCommits = array_values(array_filter($this->latestCommits, $shown)); + } + /** * Lays a first page of the log over what's showing. Rows already there change * in place; newer ones wait for the pill, unless the log had nothing to show diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index d34aeb0..2a8b704 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -19,10 +19,13 @@ use App\NativeComponents\Concerns\TalksToDevnull; use Closure; use Illuminate\View\View; +use Native\Mobile\Attributes\On; use Native\Mobile\Edge\Layouts\Builders\NavAction; use Native\Mobile\Edge\Layouts\Builders\NavBarOptions; use Native\Mobile\Edge\NativeComponent; +use Native\Mobile\Events\Alert\ButtonPressed; use Native\Mobile\Facades\Browser; +use Native\Mobile\Facades\Dialog; /** * A developer's profile: who they are on GitHub, their counts and activity, @@ -33,6 +36,10 @@ * on the web: the one who blocked is told so, the other only that it isn't * available. devnull says which by its code (see ErrorCode). * + * Someone else's profile has the web's safety menu in its nav bar: Mute or + * Unmute, and Block, or on a profile you blocked only Unblock. Each asks + * first, then changes straight away, and goes back if devnull says no. + * * The Profile tab shows your own, with no username in the route. Like the web, * your own profile swaps Follow for "Edit on GitHub", and its nav bar leads to * your account. @@ -57,6 +64,18 @@ class Profile extends NativeComponent public const array Tabs = ['commits', 'replies']; + /** + * The alerts the safety menu confirms with, as the web words them. Each one's id names the profile too, so an + * answer only reaches the profile that asked. + */ + public const string MuteAlert = 'mute'; + + public const string UnmuteAlert = 'unmute'; + + public const string BlockAlert = 'block'; + + public const string UnblockAlert = 'unblock'; + public ?ProfileData $profile = null; /** @@ -108,18 +127,54 @@ public function navTitle(): string public function navigationOptions(): ?NavBarOptions { - if (! $this->isMe()) { - return null; + if ($this->isMe()) { + return NavBarOptions::make()->action( + NavAction::make('account') + ->icon(ios: Lucide::Settings2->ios(), android: Lucide::Settings2->android()) + ->a11yLabel('Account') + ->press('openAccount'), + ); } - return NavBarOptions::make()->action( - NavAction::make('account') - ->icon(ios: Lucide::Settings2->ios(), android: Lucide::Settings2->android()) - ->a11yLabel('Account') - ->press('openAccount'), + $items = $this->safetyActions(); + + return $items === [] ? null : NavBarOptions::make()->action( + NavAction::make('safety') + ->icon(ios: Lucide::Ellipsis->ios(), android: Lucide::Ellipsis->android()) + ->a11yLabel('More options for @'.$this->username) + ->items($items), ); } + /** + * 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 + * them. + * + * @return list + */ + private function safetyActions(): array + { + $handle = '@'.$this->username; + + if ($this->isBlocking()) { + return [NavAction::make('unblock')->label("Unblock {$handle}")->icon(ios: Lucide::Ban->ios(), android: Lucide::Ban->android())->press('confirmBlock')]; + } + + if (! $this->canChangeSafety()) { + return []; + } + + $muting = $this->profile?->isMuting === true; + $muteIcon = $muting ? Lucide::Volume2 : Lucide::VolumeX; + + 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'), + ]; + } + /** * Pull to refresh. You're at the top, so new commits go straight in. */ @@ -202,6 +257,151 @@ public function openConnections(string $type): void } } + /** + * Mute or Unmute, from the menu. Mute says what it does in the web's words. + */ + public function confirmMute(): void + { + if (! $this->canChangeSafety()) { + return; + } + + $handle = '@'.$this->username; + + $this->profile?->isMuting === true + ? Dialog::alert("Unmute {$handle}?", '', [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Unmute'], + ])->id($this->alertId(self::UnmuteAlert)) + : Dialog::alert("Mute {$handle}?", "You won't see their commits or hear from them, and they won't know.", [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Mute'], + ])->id($this->alertId(self::MuteAlert)); + } + + /** + * Block or Unblock, from the menu, in the web's words: its Block dialog, + * and for Unblock what the web's Safety settings say of it. + */ + public function confirmBlock(): void + { + $handle = '@'.$this->username; + + if ($this->isBlocking()) { + Dialog::alert("Unblock {$handle}?", "Unblocking doesn't bring back follows.", [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Unblock'], + ])->id($this->alertId(self::UnblockAlert)); + + return; + } + + if (! $this->canChangeSafety()) { + return; + } + + Dialog::alert("Block {$handle}?", "You'll stop following each other. Neither of you will be able to follow, reply to, or star the other, or see each other's commits. They won't be told, but they'll see your profile as unavailable. You can unblock them any time from your settings.", [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Block', 'style' => 'destructive'], + ])->id($this->alertId(self::BlockAlert)); + } + + /** + * NativePHP gives each event one listener per screen, so this one answers + * the delete alert of a commit on the profile too (see ShowsCommits). + */ + #[On(ButtonPressed::class)] + public function onAlertButton(string $label = '', ?string $id = null): void + { + $this->answerDeleteAlert($label, $id); + + if ($id === $this->alertId(self::MuteAlert) && $label === 'Mute') { + $this->mute(); + } + + if ($id === $this->alertId(self::UnmuteAlert) && $label === 'Unmute') { + $this->unmute(); + } + + if ($id === $this->alertId(self::BlockAlert) && $label === 'Block') { + $this->block(); + } + + if ($id === $this->alertId(self::UnblockAlert) && $label === 'Unblock') { + $this->unblock(); + } + } + + /** + * Mutes straight away, so the profile says Muted, and puts it back if devnull says no. + */ + public function mute(): void + { + $this->changeMute(true); + } + + public function unmute(): void + { + $this->changeMute(false); + } + + /** + * Blocks straight away: the profile becomes the blocked page. If devnull + * says no, the profile comes back as it was. + */ + public function block(): void + { + if (! $this->canChangeSafety()) { + return; + } + + $before = [$this->commits, $this->threads, $this->nextCursor]; + $this->showBlocked(); + + $done = $this->attempt(function ($devnull): bool { + $devnull->block($this->username); + + return true; + }); + + if ($done !== true) { + [$this->commits, $this->threads, $this->nextCursor] = $before; + $this->unavailable = null; + } + } + + /** + * Unblocks straight away, from the menu or the blocked page's own button, + * which the web doesn't confirm. The header shows what it knows until + * devnull sends the profile again, without the follows the block ended. + */ + public function unblock(): void + { + if (! $this->isBlocking()) { + return; + } + + $before = [$this->sketched, $this->commitsLoaded]; + $this->unavailable = null; + $this->sketched = true; + $this->commitsLoaded = false; + + $done = $this->attempt(function ($devnull): bool { + $devnull->unblock($this->username); + + return true; + }); + + if ($done !== true) { + [$this->sketched, $this->commitsLoaded] = $before; + $this->unavailable = ErrorCode::UserBlocked->value; + + return; + } + + $this->refreshProfile(showNewer: true); + } + public function isMe(): bool { return $this->username !== '' && $this->me()?->username === $this->username; @@ -241,11 +441,11 @@ public function openWebsite(): void } /** - * The web's muted and blocked people, where a block is undone, since the app can't unblock yet. + * The people you mute and block, beneath this tab. */ public function openSafety(): void { - Browser::inApp(rtrim((string) config('devnull.url'), '/').'/settings/safety'); + $this->openInTab('/safety'); } public function openGitHubProfile(): void @@ -296,22 +496,84 @@ protected function showsContent(): bool } /** - * A profile across a block has its own page, as on the web, whatever devnull's message says. + * A profile across a block has its own page, as on the web, whatever devnull's message says. devnull sends who + * you blocked with it, for the page's name and avatar. */ protected function refused(ApiException $exception): bool { - if (! $exception->is(ErrorCode::UserBlocked) && ! $exception->is(ErrorCode::UserUnavailable)) { + if ($exception->is(ErrorCode::UserBlocked)) { + if ($exception->user !== null && $exception->user->username === $this->username) { + $this->profile = new ProfileData($exception->user); + $this->sketched = true; + } + + $this->showBlocked(); + $this->error = null; + + return true; + } + + if (! $exception->is(ErrorCode::UserUnavailable)) { return false; } $this->unavailable = $exception->errorCode; + $this->forgetShownCommits(); + $this->error = null; + + return true; + } + + private function showBlocked(): void + { + $this->unavailable = ErrorCode::UserBlocked->value; + $this->forgetShownCommits(); + } + + private function forgetShownCommits(): void + { $this->commits = []; $this->threads = []; $this->nextCursor = null; $this->forgetNewCommits(); - $this->error = null; + } - return true; + private function isBlocking(): bool + { + return ! $this->isMe() && $this->unavailable === ErrorCode::UserBlocked->value; + } + + /** + * Whether you can mute or block them: someone else, whose profile devnull has sent, across no block. + */ + private function canChangeSafety(): bool + { + return $this->username !== '' && ! $this->isMe() && $this->profile !== null && ! $this->sketched && $this->unavailable === null; + } + + private function changeMute(bool $muting): void + { + if (! $this->canChangeSafety() || $this->profile?->isMuting === $muting) { + return; + } + + $before = $this->profile; + $this->profile = $before?->withMuting($muting); + + $done = $this->attempt(function ($devnull) use ($muting): bool { + $muting ? $devnull->mute($this->username) : $devnull->unmute($this->username); + + return true; + }); + + if ($done !== true) { + $this->profile = $before; + } + } + + private function alertId(string $alert): string + { + return "{$alert}:{$this->username}"; } private function refreshProfile(bool $showNewer = false): void @@ -398,7 +660,7 @@ public function render(): View return view('native.profile', [ 'myColour' => $this->myColour(), - 'isBlocked' => $this->unavailable === ErrorCode::UserBlocked->value, + 'isBlocked' => $this->isBlocking(), 'isMe' => $this->isMe(), 'newCommitsLabel' => $this->newCommitsLabel(), 'viewer' => $this->me(), diff --git a/app/NativeComponents/Safety.php b/app/NativeComponents/Safety.php new file mode 100644 index 0000000..da8ab75 --- /dev/null +++ b/app/NativeComponents/Safety.php @@ -0,0 +1,196 @@ + Client::MutedPath, 'blocked' => Client::BlockedPath]; + + /** + * @var array{muted: list, blocked: list} + */ + public array $people = ['muted' => [], 'blocked' => []]; + + /** + * @var array{muted: ?string, blocked: ?string} + */ + public array $cursors = ['muted' => null, 'blocked' => null]; + + /** + * Whether each list has had people to show, kept or fresh. Until then it shows skeleton rows. + * + * @var array{muted: bool, blocked: bool} + */ + public array $loaded = ['muted' => false, 'blocked' => false]; + + public function mount(): void + { + $this->showKept(); + $this->revalidate(); + } + + /** + * Back from a profile: take any mute or block changed there from the cache at once, then ask devnull. + */ + public function onResume(): void + { + $this->forgetRefreshes(); + $this->showKept(); + $this->revalidate(); + } + + public function navTitle(): string + { + return 'Muted and blocked'; + } + + public function unmute(string $username): void + { + $this->takeOff('muted', $username, fn (Client $devnull) => $devnull->unmute($username)); + } + + public function unblock(string $username): void + { + $this->takeOff('blocked', $username, fn (Client $devnull) => $devnull->unblock($username)); + } + + /** + * The next page of a list, under the ones showing. + */ + public function loadMore(string $group): void + { + $cursor = $this->cursors[$group] ?? null; + + if ($cursor === null) { + return; + } + + $page = $this->attempt(fn (Client $devnull): Page => $group === 'muted' ? $devnull->muted($cursor) : $devnull->blocked($cursor)); + + if ($page !== null) { + $this->people[$group] = [...$this->people[$group], ...$page->items]; + $this->cursors[$group] = $page->nextCursor; + } + } + + public function openProfile(string $username): void + { + foreach ([...$this->people['muted'], ...$this->people['blocked']] as $person) { + if ($person->username === $username) { + $this->openInTab('/users/'.$username, ['user' => $person]); + + return; + } + } + } + + protected function revalidate(): void + { + foreach (self::Groups as $group => $path) { + $this->refresh($path, [], fn (array $json) => $this->take($group, $json)); + } + } + + protected function showsContent(): bool + { + return $this->loaded['muted'] || $this->loaded['blocked']; + } + + private function showKept(): void + { + foreach (self::Groups as $group => $path) { + if (($kept = $this->cached($path)) !== null) { + $this->take($group, $kept); + } + } + } + + /** + * @param array $json + */ + private function take(string $group, array $json): void + { + $page = Page::fromArray($json, User::fromArray(...)); + + $this->people[$group] = $page->items; + $this->cursors[$group] = $page->nextCursor; + $this->loaded[$group] = true; + } + + /** + * @param Closure(Client): void $call + */ + private function takeOff(string $group, string $username, Closure $call): void + { + $before = $this->people[$group] ?? []; + $after = array_values(array_filter($before, fn (User $person): bool => $person->username !== $username)); + + if (count($after) === count($before)) { + return; + } + + $this->people[$group] = $after; + + $done = $this->attempt(function (Client $devnull) use ($call): bool { + $call($devnull); + + return true; + }); + + if ($done !== true) { + $this->people[$group] = $before; + } + } + + public function render(): View + { + return view('native.safety', [ + 'groups' => [ + [ + 'key' => 'muted', + 'title' => 'Muted', + 'description' => "Their commits and replies stay out of your logs, threads, search, and suggestions, and they can't notify you. They aren't told, and you can still visit their profile.", + 'empty' => "You haven't muted anyone.", + 'action' => 'Unmute', + 'press' => 'unmute', + ], + [ + 'key' => 'blocked', + 'title' => 'Blocked', + 'description' => "Neither of you can follow, reply to, or star the other, or see each other's commits. Unblocking doesn't bring back follows.", + 'empty' => "You haven't blocked anyone.", + 'action' => 'Unblock', + 'press' => 'unblock', + ], + ], + ]); + } +} diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index aab8440..b144119 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -26,7 +26,8 @@ /** * Your account, reached from your own profile, laid out like the web's profile * settings: your profile as devnull mirrors it from GitHub, with a sync, your - * colour, then this phone's fingerprint, signing out and deleting the account. + * colour, the people you mute and block, then this phone's fingerprint, + * signing out and deleting the account. * Where the app talks to only shows when it isn't dev-null.sh. * * It opens on you as devnull last said (or as this phone remembers you) and @@ -192,6 +193,14 @@ public function saveLane(): void }); } + /** + * The people you mute and block, as the web's Safety settings list them. + */ + public function openSafety(): void + { + $this->navigate('/profile/safety'); + } + /** * The devices page on dev-null.sh, where this phone is listed once approved. */ diff --git a/resources/icons/ban.svg b/resources/icons/ban.svg new file mode 100644 index 0000000..1176464 --- /dev/null +++ b/resources/icons/ban.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/volume-2.svg b/resources/icons/volume-2.svg new file mode 100644 index 0000000..2c19dd6 --- /dev/null +++ b/resources/icons/volume-2.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/volume-x.svg b/resources/icons/volume-x.svg new file mode 100644 index 0000000..b8fdbeb --- /dev/null +++ b/resources/icons/volume-x.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/partials/profile-unavailable.blade.php b/resources/views/native/partials/profile-unavailable.blade.php index c7d3325..a076c47 100644 --- a/resources/views/native/partials/profile-unavailable.blade.php +++ b/resources/views/native/partials/profile-unavailable.blade.php @@ -1,8 +1,8 @@ {{-- A profile across a block, worded as the web's unavailable page. The one who blocked is told so, with the person's - avatar when it's known and a way to the web's muted and blocked people, where a block is undone, since devnull has - no unblock for the app yet. The web greys the avatar out; EDGE has no filter for that, so it's faded instead. The - one who was blocked is only told that it isn't available. + avatar, which devnull sends with the block, Unblock, and a way to the people they mute and block. The web greys + the avatar out; EDGE has no filter for that, so it's faded instead. The one who was blocked is only told that it + isn't available. --}} @php($name = $profile?->user->name) @@ -14,7 +14,10 @@ @endif fatal: {{ '@'.$username }} is blocked You blocked {{ $name ?? '@'.$username }}. Neither of you can follow, reply to, or star the other, or see each other's commits. - Muted and blocked people + + Unblock {{ '@'.$username }} + Muted and blocked people + @else fatal: {{ '@'.$username }} is unavailable This profile isn't available. diff --git a/resources/views/native/safety.blade.php b/resources/views/native/safety.blade.php new file mode 100644 index 0000000..9080687 --- /dev/null +++ b/resources/views/native/safety.blade.php @@ -0,0 +1,68 @@ +{{-- + Laid out like the web's Safety settings: Muted, then Blocked, each with its heading, then its people in a hairline + box, with Unmute or Unblock beside each. An empty list is the web's dashed box. No press-* feedback on the rows: + it would keep the screen from scrolling. +--}} + + + @include('native.partials.error') + @include('native.partials.refresh-failed') + + @foreach ($groups as $group) + @php($key = $group['key']) + + @include('native.partials.settings-heading', ['title' => $group['title'], 'description' => $group['description']]) + + @if (! $loaded[$key]) + @if ($error === null) + + @foreach (range(1, 2) as $skeleton) + @if (! $loop->first) + + @endif + + + + + + + + @endforeach + + @endif + @elseif ($people[$key] === []) + + {{ $group['empty'] }} + @include('native.partials.dashed-box') + + @else + + @foreach ($people[$key] as $person) + @if (! $loop->first) + + @endif + + + + @include('native.partials.rail-avatar', ['user' => $person, 'lane' => $person->lane(), 'size' => 44]) + + {{ $person->name }} + {{ $person->handle() }} + + + + {{ $group['action'] }} + + @endforeach + + + @if ($cursors[$key] !== null) + + Show more + + @endif + @endif + + @endforeach + + diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 00962cb..0efc53d 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -108,6 +108,17 @@ @endif + + @include('native.partials.settings-heading', [ + 'title' => 'Safety', + 'description' => 'Everyone you mute or block, and a way to undo it.', + ]) + + + Muted and blocked people + + + @if ($offersApple) @include('native.partials.settings-heading', ['title' => 'Apple ID']) diff --git a/routes/mobile.php b/routes/mobile.php index b2c7e74..c2bfe38 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -9,6 +9,7 @@ use App\NativeComponents\Notifications; use App\NativeComponents\Onboarding; use App\NativeComponents\Profile; +use App\NativeComponents\Safety; use App\NativeComponents\Saved; use App\NativeComponents\ThisDevice; use Illuminate\Support\Facades\Route; @@ -24,11 +25,13 @@ Route::native('/profile', Profile::class); Route::native('/profile/device', ThisDevice::class); - // Commits, profiles and their followers open beneath the tab they were tapped - // in, so that tab's own navigation stack pushes them (see Concerns\OpensInTab). + // Commits, profiles, their followers and the people you mute and block open + // beneath the tab they were tapped in, so that tab's own navigation stack + // pushes them (see Concerns\OpensInTab). foreach (TabsLayout::Tabs as $tab) { Route::native("{$tab}/commits/{id}", CommitDetail::class); Route::native("{$tab}/users/{username}", Profile::class); Route::native("{$tab}/users/{username}/{type}", Connections::class)->whereIn('type', Connections::Types); + Route::native("{$tab}/safety", Safety::class); } }); diff --git a/tests/Feature/Devnull/ClientCacheTest.php b/tests/Feature/Devnull/ClientCacheTest.php index faf34e5..05eafbe 100644 --- a/tests/Feature/Devnull/ClientCacheTest.php +++ b/tests/Feature/Devnull/ClientCacheTest.php @@ -231,3 +231,115 @@ expect(kept('log/following')['data'][0]['stars_count'])->toBe(1); }); + +it('keeps mutes on the kept profile and the people you mute, and takes their commits out of the kept logs', function () { + $ada = userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace']); + keep('users/ada', ['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'is_muting' => false]); + keep('users/ada/commits', pageJson([commitJson(['id' => 'c2', 'author' => $ada])])); + keep('me/muted', pageJson([userJson(['id' => 'u3', 'username' => 'grace'])])); + keep('log/following', [...pageJson([commitJson(['id' => 'c2', 'author' => $ada]), commitJson(['id' => 'c1'])]), 'suggestions' => [commitJson(['id' => 'c4', 'author' => $ada])]]); + keep('log/trending', [...pageJson([commitJson(['id' => 'c3', 'author' => $ada])]), 'is_ranked' => true]); + serveApi([ + 'POST /api/v1/users/ada/mute' => [['data' => $ada, 'is_muting' => true]], + 'DELETE /api/v1/users/ada/mute' => [['data' => $ada, 'is_muting' => false]], + ]); + + $this->devnull->mute('ada'); + + expect(kept('users/ada')['is_muting'])->toBeTrue() + ->and(array_column(kept('me/muted')['data'], 'username'))->toBe(['ada', 'grace']) + ->and(array_column(kept('log/following')['data'], 'id'))->toBe(['c1']) + ->and(kept('log/following')['suggestions'])->toBe([]) + ->and(kept('log/trending')['data'])->toBe([]) + // Their own profile still shows their commits, as on the web. + ->and(array_column(kept('users/ada/commits')['data'], 'id'))->toBe(['c2']); + + $this->devnull->unmute('ada'); + + expect(kept('users/ada')['is_muting'])->toBeFalse() + ->and(array_column(kept('me/muted')['data'], 'username'))->toBe(['grace']); +}); + +it('keeps blocks on the people you block, and drops their profile, their commits and the follows a block ends', function () { + $ada = userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace']); + keep('users/ada', ['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'is_following' => true, 'follows_you' => true]); + keep('users/ada/commits', pageJson([commitJson(['id' => 'c2', 'author' => $ada])])); + keep('users/ada/commits', pageJson([commitJson(['id' => 'r1', 'author' => $ada])]), ['replies' => '1']); + keep('users/local_dev', ['data' => userWithCountsJson(['username' => 'local_dev'], followers: 3, following: 2)]); + keep('me/blocked', pageJson([])); + keep('log/latest', pageJson([commitJson(['id' => 'c2', 'author' => $ada]), commitJson(['id' => 'c1'])])); + serveApi([ + 'POST /api/v1/users/ada/block' => [['data' => $ada, 'is_blocking' => true]], + 'DELETE /api/v1/users/ada/block' => [['data' => $ada, 'is_blocking' => false]], + ]); + + $this->devnull->block('ada'); + + expect(kept('users/ada'))->toBeNull() + ->and(kept('users/ada/commits'))->toBeNull() + ->and(kept('users/ada/commits', ['replies' => '1']))->toBeNull() + ->and(kept('users/local_dev')['data'])->toMatchArray(['followers_count' => 2, 'following_count' => 1]) + ->and(array_column(kept('me/blocked')['data'], 'username'))->toBe(['ada']) + ->and(array_column(kept('log/latest')['data'], 'id'))->toBe(['c1']); + + $this->devnull->unblock('ada'); + + // Unblocking doesn't bring back the follows. + expect(kept('me/blocked')['data'])->toBe([]) + ->and(kept('users/local_dev')['data'])->toMatchArray(['followers_count' => 2, 'following_count' => 1]); +}); + +it('keeps your counts when you block someone with no follows between you', function () { + keep('users/ada', ['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'is_following' => false, 'follows_you' => false]); + keep('users/local_dev', ['data' => userWithCountsJson(['username' => 'local_dev'], followers: 3, following: 2)]); + serveApi(['POST /api/v1/users/ada/block' => [['data' => userJson(['id' => 'u2', 'username' => 'ada']), 'is_blocking' => true]]]); + + $this->devnull->block('ada'); + + expect(kept('users/local_dev')['data'])->toMatchArray(['followers_count' => 3, 'following_count' => 2]); +}); + +it('lists the people you mute and block, and keeps their first pages', function () { + serveApi([ + 'GET /api/v1/me/muted' => [pageJson([userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace'])], 'next')], + 'GET /api/v1/me/blocked' => [pageJson([userJson(['id' => 'u3', 'username' => 'grace'])])], + ]); + + $muted = $this->devnull->muted(); + $blocked = $this->devnull->blocked(); + + expect(array_map(fn ($user): string => $user->username, $muted->items))->toBe(['ada']) + ->and($muted->items[0]->name)->toBe('Ada Lovelace') + ->and($muted->nextCursor)->toBe('next') + ->and(array_map(fn ($user): string => $user->username, $blocked->items))->toBe(['grace']) + ->and($blocked->nextCursor)->toBeNull() + ->and(kept('me/muted')['data'][0]['username'])->toBe('ada') + ->and(kept('me/blocked')['data'][0]['username'])->toBe('grace'); + + $this->devnull->muted('next'); + + expect(kept('me/muted', ['cursor' => 'next']))->toBeNull(); +}); + +it('leaves the cache alone when a mute or block fails, or devnull answers without the user', function (string $route, Closure $call) { + keep('users/ada', ['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'is_muting' => false]); + keep('me/blocked', pageJson([])); + keep('log/latest', pageJson([commitJson(['id' => 'c2', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])); + serveApi([ + $route => [['message' => 'Too Many Attempts.'], 429], + 'DELETE /api/v1/users/ada/mute' => [['is_muting' => false]], + ]); + + try { + $call($this->devnull); + } catch (ApiException) { + } + + expect(kept('users/ada')['is_muting'])->toBeFalse() + ->and(kept('me/blocked')['data'])->toBe([]) + ->and(kept('log/latest')['data'])->toHaveCount(1); +})->with([ + 'a mute' => ['POST /api/v1/users/ada/mute', fn (Client $devnull) => $devnull->mute('ada')], + 'a block' => ['POST /api/v1/users/ada/block', fn (Client $devnull) => $devnull->block('ada')], + 'an answer without the user' => ['POST /api/v1/users/ada/mute', fn (Client $devnull) => $devnull->unmute('ada')], +]); diff --git a/tests/Feature/Screens/ProfileSafetyTest.php b/tests/Feature/Screens/ProfileSafetyTest.php new file mode 100644 index 0000000..aa7663b --- /dev/null +++ b/tests/Feature/Screens/ProfileSafetyTest.php @@ -0,0 +1,344 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Ada, as devnull sends a user to a signed request. + * + * @return array + */ +function safetyAdaJson(): array +{ + return userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace', 'lane' => 1]); +} + +/** + * Ada's signed profile, which you follow. + * + * @param array $extra + * @return array + */ +function safetyProfileJson(array $extra = []): array +{ + return [ + 'data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace', 'lane' => 1], commits: 1, followers: 5, following: 2), + 'is_following' => true, + 'follows_you' => false, + 'is_muting' => false, + 'activity' => ['scale' => 'week', 'buckets' => []], + 'streaks' => ['longest' => 0, 'current' => 0], + 'pinned_commit' => null, + ...$extra, + ]; +} + +/** + * What devnull answers across a block you made: why, and who you blocked. + * + * @return array{0: array, 1: int} + */ +function safetyBlockedAnswer(): array +{ + return [['message' => "You blocked Ada Lovelace. Neither of you can follow, reply to, or star the other, or see each other's commits.", 'code' => 'user_blocked', 'user' => safetyAdaJson()], 403]; +} + +/** + * The items of the profile's safety menu, by id, or null when it has none. + * + * @return array>|null + */ +function safetyMenu(TestableComponent $screen): ?array +{ + $menu = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'top_bar_action' && ($node['props']['id'] ?? null) === 'safety'); + + if ($menu === null) { + return null; + } + + return collect($menu['children'] ?? [])->mapWithKeys(fn (array $item): array => [$item['props']['id'] => $item['props']])->all(); +} + +/** + * Whether the app sent devnull a request. + */ +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 () { + fakeApi(['GET /api/v1/users/ada' => [safetyProfileJson()], 'GET /api/v1/users/ada/commits' => [pageJson([])]]); + + $screen = Native::visit('/log/users/ada', platform: 'ios'); + $more = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'top_bar_action' && ($node['props']['id'] ?? null) === 'safety'); + + expect($more['props'])->toMatchArray(['icon' => 'custom:ellipsis', 'a11y_label' => 'More options for @ada']) + ->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], + ]); +}); + +it('offers Unmute on a profile you mute', function () { + fakeApi(['GET /api/v1/users/ada' => [safetyProfileJson(['is_muting' => true])], 'GET /api/v1/users/ada/commits' => [pageJson([])]]); + + expect(safetyMenu(Native::visit('/log/users/ada', platform: 'ios')))->toMatchArray([ + 'unmute' => ['id' => 'unmute', 'icon' => 'custom:volume-2', 'label' => 'Unmute @ada'], + ]); +}); + +it('has no safety menu on your own profile, on one that blocked you, or before devnull says whether you mute them', function () { + fakeApi([ + 'GET /api/v1/users/local_dev' => [safetyProfileJson(['data' => userWithCountsJson(['username' => 'local_dev'])])], + 'GET /api/v1/users/local_dev/commits' => [pageJson([])], + 'GET /api/v1/users/grace' => [['message' => "This profile isn't available.", 'code' => 'user_unavailable'], 403], + 'GET /api/v1/users/grace/commits' => [['message' => "This profile isn't available.", 'code' => 'user_unavailable'], 403], + ]); + + expect(safetyMenu(Native::visit('/profile')))->toBeNull() + ->and(safetyMenu(Native::visit('/log/users/grace')))->toBeNull(); + + holdRefreshes(); + + // Opened from a commit, the header only has what the commit knew of them. + expect(safetyMenu(Native::visit('/log/users/ada', data: ['user' => User::fromArray(safetyAdaJson())])))->toBeNull(); +}); + +it("mutes once you confirm, in the web's words, and Cancel leaves it as it was", function () { + keep('users/ada', safetyProfileJson()); + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson()], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + 'POST /api/v1/users/ada/mute' => [['data' => safetyAdaJson(), 'is_muting' => true]], + ]); + + $screen = Native::visit('/log/users/ada', platform: 'ios') + ->press('confirmMute') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Mute @ada?' + && $params['message'] === "You won't see their commits or hear from them, and they won't know." + && $params['buttons'] === [['label' => 'Cancel', 'style' => 'cancel'], ['label' => 'Mute', 'style' => 'default']]) + ->emitNative(ButtonPressed::class, ['index' => 0, 'label' => 'Cancel', 'id' => 'mute:ada']) + ->assertMissingElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'muted'); + + expect(safetySent('POST', 'users/ada/mute'))->toBeFalse(); + + $screen->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Mute', 'id' => 'mute:ada']) + ->assertElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'muted') + ->assertSee('Muted'); + + 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']); +}); + +it('unmutes once you confirm', function () { + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson(['is_muting' => true])], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + 'DELETE /api/v1/users/ada/mute' => [['data' => safetyAdaJson(), 'is_muting' => false]], + ]); + + $screen = Native::visit('/log/users/ada') + ->assertSee('Muted') + ->press('confirmMute') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Unmute @ada?') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Unmute', 'id' => 'unmute:ada']) + ->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']); +}); + +it('puts the mute back, and says why, when devnull says no', function () { + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson()], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + 'POST /api/v1/users/ada/mute' => [['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429], + ]); + + $screen = Native::visit('/log/users/ada') + ->call('mute') + ->assertSee('Too Many Attempts.') + ->assertMissingElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'muted'); + + expect($screen->instance()->profile->isMuting)->toBeFalse() + ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block']); +}); + +it("blocks once you confirm with the web's Block dialog, and shows the blocked page", function () { + keep('users/ada', safetyProfileJson()); + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson()], + 'GET /api/v1/users/ada/commits' => [pageJson([commitJson(['id' => 'c1', 'body' => 'Notes on the engine', 'author' => safetyAdaJson()])])], + 'POST /api/v1/users/ada/block' => [['data' => safetyAdaJson(), 'is_blocking' => true]], + ]); + + $screen = Native::visit('/log/users/ada', platform: 'ios') + ->assertSee('Notes on the engine') + ->press('confirmBlock') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Block @ada?' + && $params['message'] === "You'll stop following each other. Neither of you will be able to follow, reply to, or star the other, or see each other's commits. They won't be told, but they'll see your profile as unavailable. You can unblock them any time from your settings." + && $params['buttons'] === [['label' => 'Cancel', 'style' => 'cancel'], ['label' => 'Block', 'style' => 'destructive']]) + ->emitNative(ButtonPressed::class, ['index' => 0, 'label' => 'Cancel', 'id' => 'block:ada']) + ->assertSee('Notes on the engine'); + + expect(safetySent('POST', 'users/ada/block'))->toBeFalse(); + + $screen->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Block', 'id' => 'block:ada']) + ->assertSet('unavailable', ErrorCode::UserBlocked->value) + ->assertSee('fatal: @ada is blocked') + ->assertSee("You blocked Ada Lovelace. Neither of you can follow, reply to, or star the other, or see each other's commits.") + ->assertSee('Unblock @ada') + ->assertDontSee('Notes on the engine'); + + 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. + ->and(safetyMenu($screen))->toBe([ + 'unblock' => ['id' => 'unblock', 'icon' => 'custom:ban', 'label' => 'Unblock @ada'], + ]); +}); + +it("puts the profile back as it was when devnull won't block", function () { + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson()], + 'GET /api/v1/users/ada/commits' => [pageJson([commitJson(['id' => 'c1', 'body' => 'Notes on the engine', 'author' => safetyAdaJson()])])], + 'POST /api/v1/users/ada/block' => [['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429], + ]); + + $screen = Native::visit('/log/users/ada') + ->call('block') + ->assertSet('unavailable', null) + ->assertSee('Too Many Attempts.') + ->assertSee('Notes on the engine') + ->assertDontSee('is blocked'); + + expect(array_keys(safetyMenu($screen)))->toBe(['mute', 'block']); +}); + +it('shows who you blocked as devnull sends them, with nothing kept', function () { + fakeApi(['GET /api/v1/users/ada' => safetyBlockedAnswer(), 'GET /api/v1/users/ada/commits' => safetyBlockedAnswer()]); + + $screen = Native::visit('/log/users/ada') + ->assertSee('fatal: @ada is blocked') + ->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']); +}); + +it("unblocks from the menu once you confirm, in the web's words, and shows the profile again without the follows", function () { + serveApi([ + 'GET /api/v1/users/ada' => safetyBlockedAnswer(), + 'GET /api/v1/users/ada/commits' => safetyBlockedAnswer(), + 'DELETE /api/v1/users/ada/block' => [['data' => safetyAdaJson(), 'is_blocking' => false]], + ]); + + $screen = Native::visit('/log/users/ada') + ->press('confirmBlock') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Unblock @ada?' + && $params['message'] === "Unblocking doesn't bring back follows." + && $params['buttons'] === [['label' => 'Cancel', 'style' => 'cancel'], ['label' => 'Unblock', 'style' => 'default']]) + ->emitNative(ButtonPressed::class, ['index' => 0, 'label' => 'Cancel', 'id' => 'unblock:ada']) + ->assertSee('fatal: @ada is blocked'); + + expect(safetySent('DELETE', 'users/ada/block'))->toBeFalse(); + + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson(['is_following' => false])], + 'GET /api/v1/users/ada/commits' => [pageJson([commitJson(['id' => 'c1', 'body' => 'Notes on the engine', 'author' => safetyAdaJson()])])], + 'DELETE /api/v1/users/ada/block' => [['data' => safetyAdaJson(), 'is_blocking' => false]], + ]); + + $screen->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Unblock', 'id' => 'unblock:ada']) + ->assertSet('unavailable', null) + ->assertDontSee('is blocked') + ->assertSee('Ada Lovelace') + ->assertSee('Follow') + ->assertDontSee('Unfollow') + ->assertSee('Notes on the engine'); + + expect(safetySent('DELETE', 'users/ada/block'))->toBeTrue() + ->and(array_keys(safetyMenu($screen)))->toBe(['mute', 'block']); +}); + +it("unblocks straight away from the blocked page's own button, as the web's does, and stays blocked when devnull says no", function () { + serveApi([ + 'GET /api/v1/users/ada' => safetyBlockedAnswer(), + 'GET /api/v1/users/ada/commits' => safetyBlockedAnswer(), + 'DELETE /api/v1/users/ada/block' => [['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429], + ]); + + $screen = Native::visit('/log/users/ada') + ->tap('unblock') + ->assertNativeNotCalled('Dialog.Alert') + ->assertSet('unavailable', ErrorCode::UserBlocked->value) + ->assertSee('Too Many Attempts.') + ->assertSee('fatal: @ada is blocked'); + + expect(safetySent('DELETE', 'users/ada/block'))->toBeTrue(); +}); + +it('opens the people you mute and block beneath the tab, from a profile you blocked', function () { + fakeApi(['GET /api/v1/users/ada' => safetyBlockedAnswer(), 'GET /api/v1/users/ada/commits' => safetyBlockedAnswer()]); + + Native::visit('/log/users/ada') + ->tap('muted-and-blocked') + ->assertNavigatedTo('/log/safety') + ->assertNativeNotCalled('Browser.OpenInApp'); +}); + +it('leaves an alert another profile asked alone', function () { + serveApi([ + 'GET /api/v1/users/ada' => [safetyProfileJson()], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + 'POST /api/v1/users/ada/mute' => [['data' => safetyAdaJson(), 'is_muting' => true]], + 'POST /api/v1/users/ada/block' => [['data' => safetyAdaJson(), 'is_blocking' => true]], + ]); + + Native::visit('/log/users/ada') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Mute', 'id' => 'mute:grace']) + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Block', 'id' => 'block:grace']) + ->assertSet('unavailable', null); + + expect(safetySent('POST', 'users/ada/mute'))->toBeFalse() + ->and(safetySent('POST', 'users/ada/block'))->toBeFalse(); +}); + +it('takes the commits of someone you mute out of the log as soon as you come back to it, Trending too', function () { + holdRefreshes(); + app(ShownTabs::class)->remember(userJson()['id'], 'log', 'trending'); + keep('log/trending', [ + ...pageJson([commitJson(['id' => 't2', 'body' => 'Ada on Trending', 'author' => safetyAdaJson()]), commitJson(['id' => 't1', 'body' => 'Someone else on Trending'])], path: 'http://devnull.test/api/v1/log/trending'), + 'log' => 'trending', + 'is_ranked' => true, + 'newest_commit_id' => 't2', + 'suggestions' => [], + ]); + keep('users/ada', safetyProfileJson()); + serveApi(['POST /api/v1/users/ada/mute' => [['data' => safetyAdaJson(), 'is_muting' => true]]]); + + $log = Native::visit('/log')->assertSee('Ada on Trending'); + + $profile = $log->call('openProfile', 'ada')->follow() + ->press('confirmMute') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Mute', 'id' => 'mute:ada']); + + $profile->goBack() + ->assertDontSee('Ada on Trending') + ->assertSee('Someone else on Trending'); +}); diff --git a/tests/Feature/Screens/ProfileStandingTest.php b/tests/Feature/Screens/ProfileStandingTest.php index 93b6b70..967f1f1 100644 --- a/tests/Feature/Screens/ProfileStandingTest.php +++ b/tests/Feature/Screens/ProfileStandingTest.php @@ -115,7 +115,7 @@ function standingJson(array $extra = []): array ->assertDontSee('Whatever /dev/null says') ->assertDontSee('Follow'); - $screen->tap('muted-and-blocked')->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/settings/safety'); + $screen->tap('muted-and-blocked')->assertNavigatedTo('/log/safety'); expect(kept('users/ada'))->toBeNull(); }); diff --git a/tests/Feature/Screens/SafetyTest.php b/tests/Feature/Screens/SafetyTest.php new file mode 100644 index 0000000..92453f8 --- /dev/null +++ b/tests/Feature/Screens/SafetyTest.php @@ -0,0 +1,264 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Someone you mute or block, as devnull lists them. + * + * @return array + */ +function listedJson(string $id, string $username, string $name): array +{ + return userJson(['id' => $id, 'username' => $username, 'name' => $name, 'lane' => 2]); +} + +/** + * A page of the people you mute or block. + * + * @param list> $people + * @return array + */ +function peopleJson(array $people, ?string $cursor = null, string $path = 'me/muted'): array +{ + return pageJson($people, $cursor, path: "http://devnull.test/api/v1/{$path}"); +} + +function safetyRequests(string $method, string $path): int +{ + return count(Http::recorded(fn (Request $request): bool => $request->method() === $method && parse_url($request->url(), PHP_URL_PATH) === "/api/v1/{$path}")); +} + +it("lists the people you mute and block in the web's words, beneath the Profile tab", function () { + fakeApi([ + 'GET /api/v1/me/muted' => [peopleJson([listedJson('u2', 'ada', 'Ada Lovelace'), listedJson('u3', 'grace', 'Grace Hopper')])], + 'GET /api/v1/me/blocked' => [peopleJson([listedJson('u4', 'mallory', 'Mallory')], path: 'me/blocked')], + ]); + + $screen = Native::visit('/profile/safety', platform: 'ios') + ->assertNavTitle('Muted and blocked') + ->assertTabActive('Profile') + ->assertSee('Muted') + ->assertSee("Their commits and replies stay out of your logs, threads, search, and suggestions, and they can't notify you. They aren't told, and you can still visit their profile.") + ->assertSee('Blocked') + ->assertSee("Neither of you can follow, reply to, or star the other, or see each other's commits. Unblocking doesn't bring back follows.") + ->assertSee('Ada Lovelace')->assertSee('@ada') + ->assertSee('Grace Hopper')->assertSee('@grace') + ->assertSee('Mallory')->assertSee('@mallory') + ->assertDontSee("You haven't"); + + expect(childRefs($screen, 'muted-people'))->toContain('muted-ada', 'muted-grace') + ->and(childRefs($screen, 'blocked-people'))->toContain('blocked-mallory') + ->and(nodeByRef($screen, 'unmute-ada')['props']['label'] ?? null)->toBe('Unmute') + ->and(nodeByRef($screen, 'unblock-mallory')['props']['label'] ?? null)->toBe('Unblock') + ->and(showsLoading($screen->tree()))->toBeFalse(); +}); + +it("says so in the web's words when you mute or block no one", function () { + fakeApi(['GET /api/v1/me/muted' => [peopleJson([])], 'GET /api/v1/me/blocked' => [peopleJson([], path: 'me/blocked')]]); + + $screen = Native::visit('/profile/safety') + ->assertSee("You haven't muted anyone.") + ->assertSee("You haven't blocked anyone."); + + nodeByRef($screen, 'muted-empty'); + nodeByRef($screen, 'blocked-empty'); +}); + +it('shows skeleton rows until devnull answers, with nothing kept', function () { + holdRefreshes(); + serveApi([ + 'GET /api/v1/me/muted' => [peopleJson([listedJson('u2', 'ada', 'Ada Lovelace')])], + 'GET /api/v1/me/blocked' => [peopleJson([], path: 'me/blocked')], + ]); + + $screen = Native::visit('/profile/safety')->assertDontSee('Ada Lovelace')->assertDontSee("You haven't"); + + nodeByRef($screen, 'loading-muted-1'); + nodeByRef($screen, 'loading-blocked-1'); + + finishRefreshes($screen) + ->assertSee('Ada Lovelace') + ->assertSee("You haven't blocked anyone."); + + expect(showsLoading($screen->tree()))->toBeFalse() + ->and(kept('me/muted')['data'][0]['username'])->toBe('ada'); +}); + +it('shows the lists as they were last time at once, then takes what devnull says behind them', function () { + holdRefreshes(); + keep('me/muted', peopleJson([listedJson('u2', 'ada', 'Ada Lovelace')])); + keep('me/blocked', peopleJson([listedJson('u4', 'mallory', 'Mallory')], path: 'me/blocked')); + serveApi([ + 'GET /api/v1/me/muted' => [peopleJson([listedJson('u3', 'grace', 'Grace Hopper')])], + 'GET /api/v1/me/blocked' => [peopleJson([], path: 'me/blocked')], + ]); + + $screen = Native::visit('/profile/safety')->assertSee('Ada Lovelace')->assertSee('Mallory'); + + expect(showsLoading($screen->tree()))->toBeFalse() + ->and(refreshesSent($screen))->toBe(2); + + finishRefreshes($screen) + ->assertSee('Grace Hopper') + ->assertDontSee('Ada Lovelace') + ->assertDontSee('Mallory') + ->assertSee("You haven't blocked anyone."); +}); + +it('says quietly that it could not refresh, and keeps showing the kept lists', function () { + keep('me/muted', peopleJson([listedJson('u2', 'ada', 'Ada Lovelace')])); + keep('me/blocked', peopleJson([], path: 'me/blocked')); + fakeOffline(); + + Native::visit('/profile/safety') + ->assertSee('Ada Lovelace') + ->assertSee("warning: couldn't refresh") + ->assertDontSee('could not connect'); +}); + +it("prints the error when there's nothing kept to show", function () { + fakeOffline(); + + $screen = Native::visit('/profile/safety')->assertSee('could not connect to the server'); + + expect(showsLoading($screen->tree()))->toBeFalse(); +}); + +it('unmutes straight away, as the web does, and they leave the list and their kept profile', function () { + keep('users/ada', ['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'is_muting' => true]); + serveApi([ + 'GET /api/v1/me/muted' => [peopleJson([listedJson('u2', 'ada', 'Ada Lovelace'), listedJson('u3', 'grace', 'Grace Hopper')])], + 'GET /api/v1/me/blocked' => [peopleJson([], path: 'me/blocked')], + 'DELETE /api/v1/users/ada/mute' => [['data' => listedJson('u2', 'ada', 'Ada Lovelace'), 'is_muting' => false]], + ]); + + Native::visit('/profile/safety') + ->tap('unmute-ada') + ->assertNativeNotCalled('Dialog.Alert') + ->assertDontSee('Ada Lovelace') + ->assertSee('Grace Hopper'); + + expect(safetyRequests('DELETE', 'users/ada/mute'))->toBe(1) + ->and(array_column(kept('me/muted')['data'], 'username'))->toBe(['grace']) + ->and(kept('users/ada')['is_muting'])->toBeFalse(); +}); + +it('unblocks straight away, and they leave the list', function () { + serveApi([ + 'GET /api/v1/me/muted' => [peopleJson([])], + 'GET /api/v1/me/blocked' => [peopleJson([listedJson('u4', 'mallory', 'Mallory')], path: 'me/blocked')], + 'DELETE /api/v1/users/mallory/block' => [['data' => listedJson('u4', 'mallory', 'Mallory'), 'is_blocking' => false]], + ]); + + Native::visit('/profile/safety') + ->tap('unblock-mallory') + ->assertDontSee('Mallory') + ->assertSee("You haven't blocked anyone."); + + expect(safetyRequests('DELETE', 'users/mallory/block'))->toBe(1) + ->and(kept('me/blocked')['data'])->toBe([]); +}); + +it('puts them back, and says why, when devnull says no', function (string $route, string $button, string $name) { + serveApi([ + 'GET /api/v1/me/muted' => [peopleJson([listedJson('u2', 'ada', 'Ada Lovelace')])], + 'GET /api/v1/me/blocked' => [peopleJson([listedJson('u4', 'mallory', 'Mallory')], path: 'me/blocked')], + $route => [['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429], + ]); + + Native::visit('/profile/safety') + ->tap($button) + ->assertSee('Too Many Attempts.') + ->assertSee($name); +})->with([ + 'unmute' => ['DELETE /api/v1/users/ada/mute', 'unmute-ada', 'Ada Lovelace'], + 'unblock' => ['DELETE /api/v1/users/mallory/block', 'unblock-mallory', 'Mallory'], +]); + +it('ignores Unmute for someone it no longer lists', function () { + fakeApi(['GET /api/v1/me/muted' => [peopleJson([])], 'GET /api/v1/me/blocked' => [peopleJson([], path: 'me/blocked')]]); + + Native::visit('/profile/safety')->call('unmute', 'ada'); + + expect(safetyRequests('DELETE', 'users/ada/mute'))->toBe(0); +}); + +it('shows more of a list under the people showing', function () { + serveApi(function (Request $request) { + $path = parse_url($request->url(), PHP_URL_PATH); + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return match (true) { + $path === '/api/v1/me/muted' && ($query['cursor'] ?? null) === 'page2' => Http::response(peopleJson([listedJson('u3', 'grace', 'Grace Hopper')])), + $path === '/api/v1/me/muted' => Http::response(peopleJson([listedJson('u2', 'ada', 'Ada Lovelace')], 'page2')), + default => Http::response(peopleJson([], path: 'me/blocked')), + }; + }); + + $screen = Native::visit('/profile/safety') + ->assertSee('Ada Lovelace') + ->assertDontSee('Grace Hopper') + ->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'blocked-more') + ->tap('muted-more') + ->assertSee('Ada Lovelace') + ->assertSee('Grace Hopper') + ->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'muted-more'); + + expect(childRefs($screen, 'muted-people'))->toContain('muted-ada', 'muted-grace') + ->and(kept('me/muted')['data'])->toHaveCount(1); + + // Nothing more to show. + $screen->call('loadMore', 'muted'); + + expect(safetyRequests('GET', 'me/muted'))->toBe(2); +}); + +it('opens their profile beneath the tab it was opened in', function () { + fakeApi([ + 'GET /api/v1/me/muted' => [peopleJson([listedJson('u2', 'ada', 'Ada Lovelace')])], + 'GET /api/v1/me/blocked' => [peopleJson([], path: 'me/blocked')], + ]); + + Native::visit('/profile/safety')->tap('Ada Lovelace')->assertNavigatedTo('/profile/users/ada'); + Native::visit('/log/safety')->tap('Ada Lovelace')->assertNavigatedTo('/log/users/ada'); +}); + +it('comes back from a profile showing the mute made there', function () { + holdRefreshes(); + keep('me/muted', peopleJson([])); + keep('me/blocked', peopleJson([listedJson('u2', 'ada', 'Ada Lovelace')], path: 'me/blocked')); + keep('users/grace', [ + 'data' => userWithCountsJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace Hopper']), + 'is_following' => false, + 'is_muting' => false, + ]); + serveApi(['POST /api/v1/users/grace/mute' => [['data' => listedJson('u3', 'grace', 'Grace Hopper'), 'is_muting' => true]]]); + + $safety = Native::visit('/profile/safety')->assertSee("You haven't muted anyone."); + + $profile = Native::visit('/profile/users/grace') + ->press('confirmMute') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Mute', 'id' => 'mute:grace']); + + expect($profile->instance()->profile->isMuting)->toBeTrue(); + + $safety->call('onResume')->assertSee('Grace Hopper')->assertDontSee("You haven't muted anyone."); +}); + +it('leads from your account to the people you mute and block', function () { + serveApi(['GET /api/v1/me' => [currentUserJson()]]); + + Native::visit('/profile/device') + ->assertSee('Safety') + ->assertSee('Everyone you mute or block, and a way to undo it.') + ->tap('muted-and-blocked') + ->assertNavigatedTo('/profile/safety'); +});