diff --git a/app/Devnull/Data/Conversation.php b/app/Devnull/Data/Conversation.php index 52bfca1b..f376fe7e 100644 --- a/app/Devnull/Data/Conversation.php +++ b/app/Devnull/Data/Conversation.php @@ -2,13 +2,15 @@ namespace App\Devnull\Data; +use App\Devnull\Client; use Carbon\CarbonImmutable; /** * A row of the Remotes inbox, as devnull sends it: the person you talk to, or * an org's name and members, the last message, how many messages from others * you haven't pulled yet (the web's "ahead"), how far the partner pulled when - * you both share pulls, and whether you stashed it. + * you both share pulls, whether you stashed it, when you pinned it, and until + * when you muted it. */ final readonly class Conversation { @@ -26,6 +28,8 @@ public function __construct( public int $ahead = 0, public int $partnerReadThroughId = 0, public bool $isArchived = false, + public ?CarbonImmutable $pinnedAt = null, + public ?CarbonImmutable $mutedUntilAt = null, ) {} /** @@ -52,14 +56,55 @@ public static function fromArray(array $data): self ahead: is_int($data['ahead'] ?? null) ? max(0, $data['ahead']) : 0, partnerReadThroughId: is_int($data['partner_read_through_id'] ?? null) ? $data['partner_read_through_id'] : 0, isArchived: ($data['is_archived'] ?? false) === true, + pinnedAt: Commit::date($data['pinned_at'] ?? null), + mutedUntilAt: Commit::date($data['muted_until_at'] ?? null), ); } + /** + * The same row with some of it changed, as a write changes it before devnull answers. + * + * @param array $changes + */ + public function with(array $changes): self + { + return new self(...[...get_object_vars($this), ...$changes]); + } + public function isOrg(): bool { return $this->kind === 'org'; } + public function isPinned(): bool + { + return $this->pinnedAt !== null; + } + + /** + * Muted until a moment still to come, as the web reads it. + */ + public function isMuted(): bool + { + return $this->mutedUntilAt !== null && $this->mutedUntilAt->isFuture(); + } + + /** + * What the web's menu and toasts call it: the partner's handle, or the org's title. + */ + public function menuTitle(?string $viewerId): string + { + return $this->isOrg() ? $this->title($viewerId) : ($this->partner?->handle() ?? ''); + } + + /** + * Where devnull keeps its thread, and takes its pin, mute and stash. + */ + public function threadPath(): string + { + return Client::threadPath($this->partner->username ?? '', $this->isOrg() ? $this->id : null); + } + /** * The everyone else in an org, in the order they joined. * diff --git a/app/Devnull/Data/Thread.php b/app/Devnull/Data/Thread.php index 1acf3594..437d28e4 100644 --- a/app/Devnull/Data/Thread.php +++ b/app/Devnull/Data/Thread.php @@ -13,6 +13,7 @@ * `access` is the web's: open to push, a request of theirs to merge first, * your own request waiting on them, or nothing at all because they don't * follow you while requests are off (restricted) or have Remotes off (closed). + * You may have pinned it in your inbox, or muted it until some time. */ final readonly class Thread { @@ -48,6 +49,8 @@ public function __construct( public ?string $orgName = null, public array $members = [], public ?string $role = null, + public ?CarbonImmutable $pinnedAt = null, + public ?CarbonImmutable $mutedUntilAt = null, ) {} /** @@ -98,6 +101,8 @@ public static function fromArray(array $json): self orgName: is_string($org['name'] ?? null) && $org['name'] !== '' ? $org['name'] : null, members: $members, role: is_string($data['role'] ?? null) ? $data['role'] : null, + pinnedAt: Commit::date($data['pinned_at'] ?? null), + mutedUntilAt: Commit::date($data['muted_until_at'] ?? null), ); } @@ -105,4 +110,9 @@ public function isOrg(): bool { return $this->orgId !== null; } + + public function isMuted(): bool + { + return $this->mutedUntilAt !== null && $this->mutedUntilAt->isFuture(); + } } diff --git a/app/Devnull/MuteDuration.php b/app/Devnull/MuteDuration.php new file mode 100644 index 00000000..4c2c12a8 --- /dev/null +++ b/app/Devnull/MuteDuration.php @@ -0,0 +1,49 @@ + + */ + public static function choices(): array + { + return [ + self::Hour->value => 'For one hour', + self::Tomorrow->value => 'Until tomorrow', + self::Week->value => 'For one week', + self::Forever->value => 'Forever', + ]; + } + + public function until(): ?CarbonImmutable + { + return match ($this) { + self::Hour => CarbonImmutable::now()->startOfSecond()->addHour(), + self::Tomorrow => CarbonImmutable::now()->addDay()->startOfDay()->utc(), + self::Week => CarbonImmutable::now()->startOfSecond()->addWeek(), + self::Forever => CarbonImmutable::parse('9999-12-31T00:00:00Z'), + self::Off => null, + }; + } +} diff --git a/app/Devnull/RemoteMenu.php b/app/Devnull/RemoteMenu.php new file mode 100644 index 00000000..555e10de --- /dev/null +++ b/app/Devnull/RemoteMenu.php @@ -0,0 +1,61 @@ + + */ + public static function items(Conversation $conversation, ?string $viewerId): array + { + $items = []; + + if (! $conversation->isOrg()) { + $items[] = self::item($conversation, 'view-profile', 'View profile', Lucide::UserRound, 'viewProfile'); + } + + $items[] = $conversation->isPinned() + ? self::item($conversation, 'unpin', 'Unpin', Lucide::PinOff, 'unpin') + : self::item($conversation, 'pin', 'Pin', Lucide::Pin, 'pin'); + + $items[] = $conversation->isArchived + ? self::item($conversation, 'pop', 'Pop', Lucide::ArchiveRestore, 'pop') + : self::item($conversation, 'stash', 'Stash', Lucide::Archive, 'stash'); + + $items[] = $conversation->isMuted() + ? self::item($conversation, 'unmute', 'Unmute', Lucide::Bell, 'unmute') + : self::item($conversation, 'mute', 'Mute', Lucide::BellOff, 'chooseMute'); + + $items[] = NavAction::divider(); + + $items[] = $conversation->isOrg() + ? self::item($conversation, 'leave', 'Leave org', Lucide::LogOut, 'confirmLeave', destructive: true) + : self::item($conversation, 'block', 'Block '.$conversation->menuTitle($viewerId), Lucide::Ban, 'confirmBlock', destructive: true); + + return $items; + } + + private static function item(Conversation $conversation, string $action, string $label, Lucide $icon, string $method, bool $destructive = false): TopBarAction + { + return NavAction::make($action) + ->label($label) + ->icon(ios: $icon->ios(), android: $icon->android()) + ->destructive($destructive) + ->press("{$method}('{$conversation->id}')") + ->toElement() + ->ref("{$action}-{$conversation->id}"); + } +} diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 472875af..17cc10ae 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -52,6 +52,11 @@ final class ResponseCache '#^remotes/orgs/[^/]+/messages$#', ]; + /** + * The queries of the inbox's tabs, Open, Fetched and Stashed, as the Remotes screen asks for them. + */ + private const array InboxTabs = [[], ['tab' => 'fetched'], ['tab' => 'stashed']]; + /** * The kept answers that list people with whether you follow each one. */ @@ -568,6 +573,85 @@ public function blockChanged(array $json): void $this->dropFromLogs($user['id']); } + /** + * You pinned, unpinned, muted or unmuted a conversation or an org: every + * kept inbox tab with it takes the new fields, in devnull's words + * (`pinned_at`, `muted_until_at`), and sorts again as devnull does, + * pinned first. + * + * @param array $fields + */ + public function conversationChanged(string $id, array $fields): void + { + foreach (self::InboxTabs as $query) { + $this->patch(Client::RemotesPath, $query, fn (array $json): array => self::inbox( + $json, + array_map(fn (array $row): array => ($row['id'] ?? null) === $id ? [...$row, ...$fields] : $row, self::rows($json)), + )); + } + } + + /** + * You stashed or popped a conversation or an org: it leaves the tab it + * was on and joins Stashed, or Open, in its place there. + */ + public function conversationStashed(string $id, bool $stashed): void + { + $row = null; + + foreach (self::InboxTabs as $query) { + $row ??= collect(self::rows($this->get(Client::RemotesPath, $query) ?? []))->firstWhere('id', $id); + } + + $this->conversationGone($id); + + if ($row !== null) { + $this->patch(Client::RemotesPath, $stashed ? ['tab' => 'stashed'] : [], fn (array $json): array => self::inbox( + $json, + [...self::rows($json), [...$row, 'is_archived' => $stashed]], + )); + } + } + + /** + * You blocked the partner of a conversation, or left an org: it leaves every kept inbox tab. + */ + public function conversationGone(string $id): void + { + foreach (self::InboxTabs as $query) { + $this->patch(Client::RemotesPath, $query, fn (array $json): array => self::inbox( + $json, + array_values(array_filter(self::rows($json), fn (array $row): bool => ($row['id'] ?? null) !== $id)), + )); + } + } + + /** + * A kept inbox page's rows. + * + * @param array $json + * @return list> + */ + private static function rows(array $json): array + { + return array_values(array_filter(is_array($json['data'] ?? null) ? $json['data'] : [], is_array(...))); + } + + /** + * A kept inbox page with these rows, in devnull's order: pinned first, then the latest message first. + * + * @param array $json + * @param list> $rows + * @return array + */ + private static function inbox(array $json, array $rows): array + { + usort($rows, fn (array $a, array $b): int => [isset($b['pinned_at']), (string) ($b['last_message_at'] ?? ''), (string) ($b['id'] ?? '')] + <=> [isset($a['pinned_at']), (string) ($a['last_message_at'] ?? ''), (string) ($a['id'] ?? '')]); + + return [...$json, 'data' => $rows]; + } + /** * The user a mute or block answered with, and the state devnull says it left. * diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 6621d278..92a46513 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -88,6 +88,9 @@ enum Lucide case GitMerge; case Maximize; case Key; + case PinOff; + case BellOff; + case LogOut; /** * The icon the web's inbox draws for a kind of notification. @@ -187,6 +190,10 @@ public function custom(bool $filled = false): ?string self::GitMerge => 'git-merge', self::Maximize => 'maximize', self::Key => 'key', + self::Pin => 'pin', + self::PinOff => 'pin-off', + self::BellOff => 'bell-off', + self::LogOut => 'log-out', default => null, }; @@ -201,7 +208,6 @@ public function ios(bool $filled = false): Ios|string self::ChevronLeft => Ios::ChevronLeft, self::ChevronDown => Ios::ChevronDown, self::ArrowUpRight => Ios::ArrowUpRight, - self::Pin => Ios::Pin, self::ListOrdered => Ios::ListNumber, self::Building2 => Ios::Building2, self::MapPin => Ios::Mappin, @@ -219,7 +225,6 @@ public function android(bool $filled = false): AndroidSymbol|string self::ChevronLeft => AndroidOutlined::ChevronLeft, self::ChevronDown => AndroidOutlined::ExpandMore, self::ArrowUpRight => AndroidOutlined::ArrowOutward, - self::Pin => AndroidOutlined::PushPin, self::ListOrdered => AndroidOutlined::FormatListNumbered, self::Building2 => AndroidOutlined::Business, self::MapPin => AndroidOutlined::LocationOn, diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 284a684d..ee6a9ca3 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -3,13 +3,17 @@ namespace App\NativeComponents; use App\Devnull\Client; +use App\Devnull\Data\Commit; use App\Devnull\Data\Conversation as ConversationData; use App\Devnull\Data\Message; use App\Devnull\Data\Thread; use App\Devnull\Data\User; use App\Devnull\Display; +use App\Devnull\MuteDuration; use App\Devnull\Outbox; +use App\Devnull\ResponseCache; use App\Devnull\SiteLink; +use App\Devnull\Toasts; use App\Icons\Lucide; use App\Models\PendingWrite; use App\NativeComponents\Concerns\OpensInTab; @@ -166,6 +170,11 @@ class Conversation extends NativeComponent public bool $sheetOpen = false; + /** + * Mute's sheet is open, asking how long for. + */ + public bool $choosingMute = false; + /** * The messages whose reaction you changed and devnull hasn't answered for yet, which a poll leaves alone. * @@ -233,10 +242,18 @@ public function navigationOptions(): ?NavBarOptions $items[] = NavAction::make('view-profile')->label('View profile')->icon(ios: Lucide::UserRound->ios(), android: Lucide::UserRound->android())->press('viewProfile'); } + $items[] = $this->thread->pinnedAt !== null + ? NavAction::make('unpin')->label('Unpin')->icon(ios: Lucide::PinOff->ios(), android: Lucide::PinOff->android())->press('unpin') + : NavAction::make('pin')->label('Pin')->icon(ios: Lucide::Pin->ios(), android: Lucide::Pin->android())->press('pin'); + $items[] = $this->thread->isArchived ? NavAction::make('pop')->label('Pop')->icon(ios: Lucide::ArchiveRestore->ios(), android: Lucide::ArchiveRestore->android())->press('pop') : NavAction::make('stash')->label('Stash')->icon(ios: Lucide::Archive->ios(), android: Lucide::Archive->android())->press('stash'); + $items[] = $this->thread->isMuted() + ? NavAction::make('unmute')->label('Unmute')->icon(ios: Lucide::Bell->ios(), android: Lucide::Bell->android())->press('unmute') + : NavAction::make('mute')->label('Mute')->icon(ios: Lucide::BellOff->ios(), android: Lucide::BellOff->android())->press('chooseMute'); + return $options->action( NavAction::make('conversation-actions') ->icon(ios: Lucide::Ellipsis->ios(), android: Lucide::Ellipsis->android()) @@ -487,6 +504,47 @@ public function pop(): void }, fn () => null); } + /** + * Pin and Unpin, from the menu, as the inbox row's: the thread says so at once, and goes back if devnull says no. + */ + public function pin(): void + { + $this->changeThread(['pinnedAt' => CarbonImmutable::now()], 'pin', ['pinned' => true], 'pinned_at', 'Could not update the pin. Try again.'); + } + + public function unpin(): void + { + $this->changeThread(['pinnedAt' => null], 'pin', ['pinned' => false], 'pinned_at', 'Could not update the pin. Try again.'); + } + + /** + * Mute, from the menu: the sheet asks how long for, as the web's dialog does (see partials/mute-sheet). + */ + public function chooseMute(): void + { + $this->choosingMute = $this->thread !== null; + } + + public function muteFor(string $duration): void + { + $duration = MuteDuration::tryFrom($duration); + $this->choosingMute = false; + + if ($duration !== null && $duration !== MuteDuration::Off) { + $this->changeThread(['mutedUntilAt' => $duration->until()], 'mute', ['duration' => $duration->value], 'muted_until_at', 'Could not update the mute. Try again.', 'Muted '.$this->menuTitle().'.'); + } + } + + public function muteSheetClosed(): void + { + $this->choosingMute = false; + } + + public function unmute(): void + { + $this->changeThread(['mutedUntilAt' => null], 'mute', ['duration' => MuteDuration::Off->value], 'muted_until_at', 'Could not update the mute. Try again.', 'Unmuted '.$this->menuTitle().'.'); + } + public function viewProfile(): void { if ($this->username !== '') { @@ -700,6 +758,63 @@ private function showKept(): void } } + /** + * Changes the thread's pin or mute at once, and in the inbox kept for when you go back, then tells devnull. Its + * answer, `pinned_at` or `muted_until_at`, has the last word. If it says no, the thread goes back and a toast + * says so. + * + * @param array $change + * @param array $payload + */ + private function changeThread(array $change, string $endpoint, array $payload, string $answer, string $failed, ?string $done = null): void + { + $before = $this->thread; + + if ($before === null) { + return; + } + + $field = array_key_first($change); + $this->showThreadChange($change); + + $this->writeInBackground('PUT', $this->threadPath().'/'.$endpoint, $payload, function (array $json) use ($field, $answer, $done): void { + $this->showThreadChange([$field => Commit::date($json[$answer] ?? null)]); + + if ($done !== null) { + Toasts::say($done); + } + }, function () use ($field, $before, $failed): void { + $this->showThreadChange([$field => $before->{$field}]); + Toasts::failed($failed); + }); + } + + /** + * @param array $change + */ + private function showThreadChange(array $change): void + { + if ($this->thread === null) { + return; + } + + $this->thread = new Thread(...[...get_object_vars($this->thread), ...$change]); + + if ($this->row !== null) { + app(ResponseCache::class)->conversationChanged($this->row->id, [ + ['pinnedAt' => 'pinned_at', 'mutedUntilAt' => 'muted_until_at'][array_key_first($change)] => reset($change)?->toJSON(), + ]); + } + } + + /** + * What the web's menu and toasts call the thread: the partner's handle, or the org's title. + */ + private function menuTitle(): string + { + return $this->isOrg() ? $this->title() : '@'.$this->username; + } + private function threadPath(): string { return Client::threadPath($this->username, $this->org === '' ? null : $this->org); @@ -874,6 +989,8 @@ public function render(): View 'moreReactions' => self::MoreReactions, 'canPush' => $thread !== null && $thread->access === Thread::Open, 'placeholder' => $this->isOrg() ? 'Message '.($thread->orgName ?? 'the org') : 'Message @'.$this->username, + 'muting' => $this->choosingMute ? 'thread' : null, + 'mutingTitle' => $this->menuTitle(), ]); } diff --git a/app/NativeComponents/Remotes.php b/app/NativeComponents/Remotes.php index baf12f3e..1008d750 100644 --- a/app/NativeComponents/Remotes.php +++ b/app/NativeComponents/Remotes.php @@ -4,22 +4,32 @@ use App\Devnull\Account; use App\Devnull\Client; +use App\Devnull\Data\Commit; use App\Devnull\Data\Conversation as ConversationData; use App\Devnull\Exceptions\ApiException; +use App\Devnull\MuteDuration; +use App\Devnull\RemoteMenu; +use App\Devnull\ResponseCache; use App\Devnull\ShownTabs; +use App\Devnull\Toasts; use App\Icons\Lucide; use App\NativeComponents\Concerns\DrawsSkeletonFirst; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; +use App\NativeComponents\Concerns\WritesInBackground; use App\NativeComponents\Layouts\TabsLayout; +use Carbon\CarbonImmutable; use Illuminate\View\View; +use Native\Mobile\Attributes\On; use Native\Mobile\Attributes\Poll; use Native\Mobile\Edge\Layouts\Builders\NavAction; use Native\Mobile\Edge\Layouts\Builders\NavBarOptions; use Native\Mobile\Edge\NativeComponent; use Native\Mobile\Edge\Transition; +use Native\Mobile\Events\Alert\ButtonPressed; +use Native\Mobile\Facades\Dialog; /** * Remotes, the web's direct messages: your conversations and orgs, the one @@ -40,6 +50,13 @@ * * While you have Remotes off, devnull answers the inbox as the web answers * /remotes, with a 404, and the tab shows the web's page for it. + * + * A tap opens a row's thread. A long press opens the web's row menu (see + * RemoteMenu): View profile, Pin, Stash, Mute, then Block or Leave org. Each + * changes the row at once, pinned rows going to the top as devnull sorts + * them, and tells devnull from the background. If devnull says no, the row + * goes back and a toast says so in the web's words. Mute asks how long for, + * as the web's dialog does, and Block and Leave ask first. */ class Remotes extends NativeComponent { @@ -48,6 +65,7 @@ class Remotes extends NativeComponent use RefreshesInBackground; use SyncsOutbox; use TalksToDevnull; + use WritesInBackground; /** * The web's tabs. Fetched only shows while devnull takes requests. @@ -59,6 +77,13 @@ class Remotes extends NativeComponent */ public const int PreviewRows = 8; + /** + * The alerts Block and Leave org confirm with, as the web words them. Each one's id names the row too. + */ + public const string BlockAlert = 'remotes-block'; + + public const string LeaveAlert = 'remotes-leave'; + /** * Every frame goes in full, as on the Log (see Log::$forceFullFrames), since a row moves to the top when it * has a new message. @@ -99,6 +124,22 @@ class Remotes extends NativeComponent */ public bool $off = false; + /** + * The row whose Mute sheet is open, asking how long for. + */ + public ?string $muting = null; + + /** + * What writes devnull hasn't answered yet changed in the rows, by the + * row's id: a pin or a mute, or `gone` for one you stashed, blocked or + * left. A poll that answers first gets them laid over it, so a row never + * flips back and forth. Coming back to the tab forgets them, since + * NativePHP drops an answer that comes while the tab is hidden. + * + * @var array> + */ + private array $changing = []; + public function mount(): void { $this->off = app(Account::class)->remotes() === null; @@ -112,6 +153,7 @@ public function mount(): void public function onResume(): void { $this->forgetRefreshes(); + $this->changing = []; $this->off = app(Account::class)->remotes() === null; $this->showKept(); $this->revalidate(); @@ -279,6 +321,161 @@ public function openConversation(string $id): void } } + public function viewProfile(string $id): void + { + $partner = $this->row($id)?->partner; + + if ($partner !== null) { + $this->openInTab('/users/'.rawurlencode($partner->username), ['user' => $partner]); + } + } + + public function pin(string $id): void + { + $this->changePin($id, true); + } + + public function unpin(string $id): void + { + $this->changePin($id, false); + } + + /** + * Stash takes a row out of Open, and Pop out of Stashed, as the web does, saying where it went. + */ + public function stash(string $id): void + { + $this->changeStash($id, true); + } + + public function pop(string $id): void + { + $this->changeStash($id, false); + } + + /** + * Mute, from the menu: the sheet asks how long for, as the web's dialog does. + */ + public function chooseMute(string $id): void + { + if ($this->row($id) !== null) { + $this->muting = $id; + } + } + + public function muteFor(string $duration): void + { + $id = $this->muting; + $duration = MuteDuration::tryFrom($duration); + $this->muting = null; + + if ($id !== null && $duration !== null && $duration !== MuteDuration::Off) { + $this->changeMute($id, $duration); + } + } + + public function muteSheetClosed(): void + { + $this->muting = null; + } + + /** + * Unmute goes straight away, as on the web. + */ + public function unmute(string $id): void + { + $this->changeMute($id, MuteDuration::Off); + } + + /** + * Block, from the menu, asks first in the web's words. + */ + public function confirmBlock(string $id): void + { + $row = $this->row($id); + + if ($row === null || $row->isOrg()) { + return; + } + + Dialog::alert('Block '.$row->menuTitle($this->me()?->id).'?', "This conversation goes away for both of you, and neither of you can message, follow, reply to, or star the other. They won't be told. You can unblock them any time from your settings.", [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Block', 'style' => 'destructive'], + ])->id(self::BlockAlert.':'.$id); + } + + /** + * Leave org, from the menu, asks first in the web's words. + */ + public function confirmLeave(string $id): void + { + $row = $this->row($id); + + if ($row === null || ! $row->isOrg()) { + return; + } + + Dialog::alert('Leave '.$row->menuTitle($this->me()?->id).'?', 'You stop seeing the org and its history. A maintainer would have to add you back. If you are its only maintainer, the longest-standing member takes over.', [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Leave', 'style' => 'destructive'], + ])->id(self::LeaveAlert.':'.$id); + } + + #[On(ButtonPressed::class)] + public function onAlertButton(string $label = '', ?string $id = null): void + { + [$alert, $row] = array_pad(explode(':', (string) $id, 2), 2, ''); + + match (true) { + $alert === self::BlockAlert && $label === 'Block' => $this->block($row), + $alert === self::LeaveAlert && $label === 'Leave' => $this->leave($row), + default => null, + }; + } + + /** + * Blocks the partner, which takes the conversation away for both of you, so its row goes at once. + */ + public function block(string $id): void + { + $row = $this->row($id); + + if ($row === null || $row->partner === null) { + return; + } + + $partner = $row->partner; + $this->dropRow($id); + + $this->writeInBackground('POST', 'users/'.rawurlencode($partner->username).'/block', null, function (array $json) use ($id, $partner): void { + $this->settleDrop($id); + app(ResponseCache::class)->blockChanged($json); + app(ResponseCache::class)->conversationGone($id); + Toasts::say("Blocked {$partner->handle()}. Neither of you can message, follow, reply to, or star the other, or see each other's commits."); + }, fn () => $this->putBack($row, "Could not block {$partner->handle()}. Try again.")); + } + + /** + * Leaves an org: you take yourself out of its members, and its row goes at once. + */ + public function leave(string $id): void + { + $row = $this->row($id); + $me = $this->me(); + + if ($row === null || ! $row->isOrg() || $me === null) { + return; + } + + $title = $row->menuTitle($me->id); + $this->dropRow($id); + + $this->writeInBackground('DELETE', $row->threadPath().'/members/'.rawurlencode($me->username), null, function () use ($id): void { + $this->settleDrop($id); + app(ResponseCache::class)->conversationGone($id); + }, fn () => $this->putBack($row, "Could not leave {$title}. Try again.")); + } + /** * The 404's way out, as the web's: back to your log. */ @@ -287,6 +484,201 @@ public function backToLog(): void $this->replace(TabsLayout::Tabs[0])->transition(Transition::None); } + private function changePin(string $id, bool $pinned): void + { + $row = $this->row($id); + + if ($row === null || $row->isPinned() === $pinned) { + return; + } + + $this->changeRow($id, ['pinnedAt' => $pinned ? CarbonImmutable::now() : null]); + + $this->writeInBackground( + 'PUT', + $row->threadPath().'/pin', + ['pinned' => $pinned], + fn (array $json) => $this->settleRow($id, ['pinnedAt' => Commit::date($json['pinned_at'] ?? null)]), + function () use ($id, $row): void { + $this->settleRow($id, ['pinnedAt' => $row->pinnedAt]); + Toasts::failed('Could not update the pin. Try again.'); + }, + ); + } + + private function changeMute(string $id, MuteDuration $duration): void + { + $row = $this->row($id); + + if ($row === null) { + return; + } + + $title = $row->menuTitle($this->me()?->id); + $this->changeRow($id, ['mutedUntilAt' => $duration->until()]); + + $this->writeInBackground( + 'PUT', + $row->threadPath().'/mute', + ['duration' => $duration->value], + function (array $json) use ($id, $duration, $title): void { + $this->settleRow($id, ['mutedUntilAt' => Commit::date($json['muted_until_at'] ?? null)]); + Toasts::say($duration === MuteDuration::Off ? "Unmuted {$title}." : "Muted {$title}."); + }, + function () use ($id, $row): void { + $this->settleRow($id, ['mutedUntilAt' => $row->mutedUntilAt]); + Toasts::failed('Could not update the mute. Try again.'); + }, + ); + } + + private function changeStash(string $id, bool $stashed): void + { + $row = $this->row($id); + + if ($row === null || $row->isArchived === $stashed) { + return; + } + + $title = $row->menuTitle($this->me()?->id); + $this->dropRow($id); + app(ResponseCache::class)->conversationStashed($id, $stashed); + + $this->writeInBackground($stashed ? 'POST' : 'DELETE', $row->threadPath().'/archive', null, function () use ($id, $stashed, $title): void { + $this->settleDrop($id); + Toasts::say($stashed ? "Stashed {$title}. It pops back when someone writes." : "Popped {$title} back into Open."); + }, function () use ($id, $row, $stashed): void { + app(ResponseCache::class)->conversationStashed($id, ! $stashed); + $this->putBack($row, 'Could not update the conversation. Try again.'); + }); + } + + private function row(string $id): ?ConversationData + { + foreach ($this->conversations as $conversation) { + if ($conversation->id === $id) { + return $conversation; + } + } + + return null; + } + + /** + * A row changes before devnull has said so: here, in the kept inbox, and over any poll until it does. + * + * @param array $fields + */ + private function changeRow(string $id, array $fields): void + { + $this->changing[$id] = [...$this->changing[$id] ?? [], ...$fields]; + $this->applyToRow($id, $fields); + } + + /** + * devnull answered for a row's change, or turned it down: it takes these fields, and polls go back to having + * the last word. + * + * @param array $fields + */ + private function settleRow(string $id, array $fields): void + { + $this->changing[$id] = array_diff_key($this->changing[$id] ?? [], $fields); + + if ($this->changing[$id] === []) { + unset($this->changing[$id]); + } + + $this->applyToRow($id, $fields); + } + + /** + * @param array $fields + */ + private function applyToRow(string $id, array $fields): void + { + $this->conversations = self::sorted(array_map( + fn (ConversationData $conversation): ConversationData => $conversation->id === $id ? $conversation->with($fields) : $conversation, + $this->conversations, + )); + + app(ResponseCache::class)->conversationChanged($id, array_filter([ + 'pinned_at' => array_key_exists('pinnedAt', $fields) ? $fields['pinnedAt']?->toJSON() : false, + 'muted_until_at' => array_key_exists('mutedUntilAt', $fields) ? $fields['mutedUntilAt']?->toJSON() : false, + ], fn (mixed $value): bool => $value !== false)); + } + + /** + * A row leaves the tab before devnull has said so: stashed, popped, blocked or left. + */ + private function dropRow(string $id): void + { + $this->changing[$id] = [...$this->changing[$id] ?? [], 'gone' => true]; + $this->conversations = array_values(array_filter($this->conversations, fn (ConversationData $conversation): bool => $conversation->id !== $id)); + } + + private function settleDrop(string $id): void + { + unset($this->changing[$id]['gone']); + + if (($this->changing[$id] ?? null) === []) { + unset($this->changing[$id]); + } + } + + /** + * devnull turned down a row leaving the tab: it comes back where it was, if the tab is still the one it was on. + */ + private function putBack(ConversationData $row, string $error): void + { + $this->settleDrop($row->id); + + if ($this->row($row->id) === null && $row->isArchived === ($this->tab === 'stashed')) { + $this->conversations = self::sorted([...$this->conversations, $row]); + } + + Toasts::failed($error); + } + + /** + * Rows as devnull sorts them: pinned first, then the latest message first. + * + * @param list $conversations + * @return list + */ + private static function sorted(array $conversations): array + { + usort($conversations, fn (ConversationData $a, ConversationData $b): int => [$b->isPinned(), $b->lastMessageAt?->getTimestamp() ?? 0, $b->id] + <=> [$a->isPinned(), $a->lastMessageAt?->getTimestamp() ?? 0, $a->id]); + + return $conversations; + } + + /** + * A fresh page with the changes devnull hasn't answered for yet laid over it. + * + * @param list $conversations + * @return list + */ + private function withChanges(array $conversations): array + { + if ($this->changing === []) { + return $conversations; + } + + $changed = []; + + foreach ($conversations as $conversation) { + $changes = $this->changing[$conversation->id] ?? []; + + if (! ($changes['gone'] ?? false)) { + $changed[] = $conversation->with(array_diff_key($changes, ['gone' => true])); + } + } + + return self::sorted($changed); + } + /** * @return array */ @@ -352,7 +744,7 @@ private function takeFresh(string $tab, array $json): void return; } - $fresh = self::conversationsFrom($json); + $fresh = $this->withChanges(self::conversationsFrom($json)); $freshIds = array_flip(array_map(fn (ConversationData $conversation): string => $conversation->id, $fresh)); $oldest = $fresh[array_key_last($fresh) ?? -1]->lastMessageAt ?? null; @@ -401,8 +793,12 @@ public function render(): View : $this->preview($tab); } + $muting = $this->muting === null ? null : $this->row($this->muting); + return view('native.remotes', [ 'myColour' => $this->myColour(), + 'muting' => $muting, + 'mutingTitle' => $muting?->menuTitle($viewer?->id) ?? '', 'tabLabels' => array_map( fn (string $tab): string => $tab === 'fetched' && $this->requestsCount > 0 ? "{$tabs[$tab]} · {$this->requestsCount}" : $tabs[$tab], $keys, @@ -451,6 +847,7 @@ private function page(string $tab, array $conversations, bool $loaded, bool $has 'mine' => $conversation->lastMessage !== null && $conversation->lastMessage->userId === $viewer?->id, 'viewer' => $viewer, 'myColour' => $this->myColour(), + 'menu' => RemoteMenu::items($conversation, $viewer?->id), ], $conversations), ]; } diff --git a/composer.lock b/composer.lock index 431125da..f773369e 100644 --- a/composer.lock +++ b/composer.lock @@ -2723,12 +2723,12 @@ "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-air.git", - "reference": "54aae8174055a8a1ceb41488e02ee1fdfa7f7c4c" + "reference": "16b1f23383a223c1b878ae39799173de4ffa25e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/54aae8174055a8a1ceb41488e02ee1fdfa7f7c4c", - "reference": "54aae8174055a8a1ceb41488e02ee1fdfa7f7c4c", + "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/16b1f23383a223c1b878ae39799173de4ffa25e8", + "reference": "16b1f23383a223c1b878ae39799173de4ffa25e8", "shasum": "" }, "require": { @@ -2839,7 +2839,7 @@ "source": "https://github.com/NativePHP/mobile-air/tree/main", "issues": "https://github.com/NativePHP/mobile-air/issues" }, - "time": "2026-10-26T17:05:50+00:00" + "time": "2026-10-28T12:20:34+00:00" }, { "name": "nativephp/mobile-browser", @@ -3067,12 +3067,12 @@ "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-ui.git", - "reference": "1cad28cd3cd06cb1cd5fe7852a40d59a8cd26a2d" + "reference": "f08fff9a3c56ffe52572d5f0417fab00967bb256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/1cad28cd3cd06cb1cd5fe7852a40d59a8cd26a2d", - "reference": "1cad28cd3cd06cb1cd5fe7852a40d59a8cd26a2d", + "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/f08fff9a3c56ffe52572d5f0417fab00967bb256", + "reference": "f08fff9a3c56ffe52572d5f0417fab00967bb256", "shasum": "" }, "require": { @@ -3132,7 +3132,7 @@ "url": "https://nativephp.com/sponsor" } ], - "time": "2026-10-26T19:22:17+00:00" + "time": "2026-10-28T12:20:57+00:00" }, { "name": "nesbot/carbon", diff --git a/resources/icons/bell-off.svg b/resources/icons/bell-off.svg new file mode 100644 index 00000000..bce37132 --- /dev/null +++ b/resources/icons/bell-off.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/log-out.svg b/resources/icons/log-out.svg new file mode 100644 index 00000000..1caa869e --- /dev/null +++ b/resources/icons/log-out.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/pin-off.svg b/resources/icons/pin-off.svg new file mode 100644 index 00000000..629dcccc --- /dev/null +++ b/resources/icons/pin-off.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/pin.svg b/resources/icons/pin.svg new file mode 100644 index 00000000..3b5b2732 --- /dev/null +++ b/resources/icons/pin.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php index 6d3b30be..b4ad5950 100644 --- a/resources/views/native/conversation.blade.php +++ b/resources/views/native/conversation.blade.php @@ -157,4 +157,6 @@ class="w-full py-[4] text-theme-ink" @endif + + @include('native.partials.mute-sheet') diff --git a/resources/views/native/partials/mute-sheet.blade.php b/resources/views/native/partials/mute-sheet.blade.php new file mode 100644 index 00000000..b31603d9 --- /dev/null +++ b/resources/views/native/partials/mute-sheet.blade.php @@ -0,0 +1,17 @@ +{{-- + The sheet Mute opens on a Remotes conversation or org, standing in for the web's Mute dialog, in its words: how long + for, one button each, in its order. $mutingTitle is the handle or the org's name, and the sheet shows while + $muting is set. +--}} + + + Mute {{ $mutingTitle }} + Silence browser notifications and sound on all your devices. Messages and unread counts stay visible. + + + @foreach (\App\Devnull\MuteDuration::choices() as $duration => $label) + {{ $label }} + @endforeach + + + diff --git a/resources/views/native/partials/remote-row.blade.php b/resources/views/native/partials/remote-row.blade.php index af279846..9c67f07e 100644 --- a/resources/views/native/partials/remote-row.blade.php +++ b/resources/views/native/partials/remote-row.blade.php @@ -1,12 +1,23 @@ {{-- A row of the Remotes inbox, as the web draws it: the partner's avatar ringed in their lane, or two of an org's people overlapping, the name and handle (or "org · N"), the last message on one line, brighter while it has - messages you haven't pulled, then on the right how many are ahead in your lane, or when it was last written to. - Your own last message in a conversation wears the web's glyph: one dot pushed, two joined dots once pulled. + messages you haven't pulled, then on the right the web's pin when you pinned it, and how many are ahead in your + lane, or when it was last written to. Your own last message in a conversation wears the web's glyph: one dot + pushed, two joined dots once pulled. A muted row wears a crossed-out bell beside the pin. + + A tap opens the thread, and a long press the web's row menu (see App\Devnull\RemoteMenu). --}} @php($id = $conversation->id) @php($last = $conversation->lastMessage) - +@php($muted = $conversation->isMuted()) + @if ($conversation->isOrg()) @@ -48,7 +59,17 @@ - + + @if ($conversation->isPinned() || $muted) + + @if ($muted) + + @endif + @if ($conversation->isPinned()) + + @endif + + @endif @if ($conversation->ahead > 0) {{ $conversation->ahead }} ahead diff --git a/resources/views/native/remotes.blade.php b/resources/views/native/remotes.blade.php index 150824ab..8c5558e4 100644 --- a/resources/views/native/remotes.blade.php +++ b/resources/views/native/remotes.blade.php @@ -40,5 +40,7 @@ class="w-full flex-1 bg-theme-surface" @include('native.partials.remotes-page', ['page' => $page]) @endforeach + + @include('native.partials.mute-sheet') @endif diff --git a/tests/Feature/Screens/CommitMenuTest.php b/tests/Feature/Screens/CommitMenuTest.php index ff4efcb8..c07a7170 100644 --- a/tests/Feature/Screens/CommitMenuTest.php +++ b/tests/Feature/Screens/CommitMenuTest.php @@ -39,7 +39,7 @@ // Your own published one: share it, pin it to your profile, or delete it. expect(menuItems($screen, 'more-c2'))->toBe([ ['share-c2', 'Share link', 'custom:link'], - ['pin-c2', 'Pin to profile', $platform === 'ios' ? 'pin' : 'push_pin'], + ['pin-c2', 'Pin to profile', 'custom:pin'], 'divider', ['delete-c2', 'Delete commit', 'custom:trash-2'], ]) @@ -199,7 +199,7 @@ expect($screen->instance()->pinnedCommit()?->id)->toBe('c2') ->and(array_column($screen->instance()->commits, 'id'))->toBe(['c1']) ->and(nodeByRef($screen, 'pinned'))->not->toBeEmpty() - ->and(menuItems($screen, 'more-c2'))->toContain(['pin-c2', 'Unpin from profile', 'pin']); + ->and(menuItems($screen, 'more-c2'))->toContain(['pin-c2', 'Unpin from profile', 'custom:pin']); $screen->tap('pin-c2') ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Commit unpinned.'); @@ -209,7 +209,7 @@ // Back in your commits, in order, with Pin to profile again. expect($screen->instance()->pinnedCommit())->toBeNull() ->and(array_column($screen->instance()->commits, 'id'))->toBe(['c2', 'c1']) - ->and(menuItems($screen, 'more-c2'))->toContain(['pin-c2', 'Pin to profile', 'pin']); + ->and(menuItems($screen, 'more-c2'))->toContain(['pin-c2', 'Pin to profile', 'custom:pin']); }); it("leaves Pin to profile off a reply, and off someone else's commit", function () { diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index dcc9d32e..5f00b8e5 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -514,7 +514,7 @@ $screen = Native::visit('/log', platform: 'ios'); expect(menuItems($screen, 'more-c1'))->toBe([ ['share-c1', 'Share link', 'custom:link'], - ['pin-c1', 'Pin to profile', 'pin'], + ['pin-c1', 'Pin to profile', 'custom:pin'], 'divider', ['delete-c1', 'Delete commit', 'custom:trash-2'], ]) diff --git a/tests/Feature/Screens/RemotesMenuTest.php b/tests/Feature/Screens/RemotesMenuTest.php new file mode 100644 index 00000000..6f97c9aa --- /dev/null +++ b/tests/Feature/Screens/RemotesMenuTest.php @@ -0,0 +1,397 @@ + 'http://devnull.test']); + signIn(); + $this->travelTo('2026-10-19 15:30:00'); +}); + +/** + * An org with Ada and Grace in it, called "Mobile app testers". + * + * @param array $overrides + * @return array + */ +function orgConversationJson(array $overrides = []): array +{ + return conversationJson([ + 'id' => 'org-1', + 'kind' => 'org', + 'name' => 'Mobile app testers', + 'partner' => null, + 'members' => [ + ['user' => userJson(), 'role' => 'maintainer'], + ['user' => userJson(['id' => 'u2', 'handle' => 'ada', 'name' => 'Ada Lovelace']), 'role' => 'contributor'], + ['user' => userJson(['id' => 'u3', 'handle' => 'grace', 'name' => 'Grace Hopper']), 'role' => 'contributor'], + ], + ...$overrides, + ]); +} + +/** + * Whether anything with this ref was rendered. + */ +function rendersRef(TestableComponent $screen, string $ref): bool +{ + return findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === $ref) !== null; +} + +/** + * The inbox with Ada's conversation and the org, then devnull taking every write: the pin, mute and stash answers + * devnull sends, and a block or a leave. + * + * @param list> $conversations + */ +function serveInbox(array $conversations, int $writeStatus = 200): void +{ + serveApi(fn (Request $request) => match (true) { + $request->method() === 'GET' => Http::response(remotesInboxJson($conversations)), + $writeStatus !== 200 => Http::response(['message' => 'Server Error'], $writeStatus), + str_ends_with($request->url(), '/pin') => Http::response(['pinned_at' => $request['pinned'] ? '2026-10-19T15:30:05.000000Z' : null]), + str_ends_with($request->url(), '/mute') => Http::response(['muted_until_at' => match ($request['duration']) { + 'hour' => '2026-10-19T16:30:00.000000Z', + 'forever' => '9999-12-31T00:00:00.000000Z', + default => null, + }]), + str_ends_with($request->url(), '/block') => Http::response(['data' => userJson(['id' => 'u2', 'handle' => 'ada']), 'is_blocking' => true]), + default => Http::response(null, 204), + }); +} + +it('opens the web row menu on a long press, in its order and words, and still opens the thread on a tap', function () { + serveInbox([conversationJson(), orgConversationJson()]); + + $screen = finishRefreshes(Native::visit('/remotes', platform: 'ios')); + + expect(nodeByRef($screen, 'remote-conv-1')['props']['has_context_menu'] ?? null)->toBeTrue() + ->and(menuItems($screen, 'remote-conv-1'))->toBe([ + ['view-profile-conv-1', 'View profile', 'custom:user-round'], + ['pin-conv-1', 'Pin', 'custom:pin'], + ['stash-conv-1', 'Stash', 'custom:archive'], + ['mute-conv-1', 'Mute', 'custom:bell-off'], + 'divider', + ['block-conv-1', 'Block @ada', 'custom:ban'], + ]) + ->and(menuItems($screen, 'remote-org-1'))->toBe([ + ['pin-org-1', 'Pin', 'custom:pin'], + ['stash-org-1', 'Stash', 'custom:archive'], + ['mute-org-1', 'Mute', 'custom:bell-off'], + 'divider', + ['leave-org-1', 'Leave org', 'custom:log-out'], + ]) + ->and(nodeByRef($screen, 'block-conv-1')['props']['destructive'] ?? null)->toBeTrue() + ->and(nodeByRef($screen, 'leave-org-1')['props']['destructive'] ?? null)->toBeTrue(); + + $screen->tap('remote-conv-1')->assertNavigatedTo('/remotes/conversations/ada'); +}); + +it('offers Unpin, Pop and Unmute on a row that is pinned, stashed and muted', function () { + serveInbox([conversationJson(['is_archived' => true, 'pinned_at' => '2026-10-19T12:00:00.000000Z', 'muted_until_at' => '2026-10-19T16:00:00.000000Z'])]); + + $screen = finishRefreshes(Native::visit('/remotes', platform: 'ios')); + + expect(array_column(array_filter(menuItems($screen, 'remote-conv-1'), is_array(...)), 1))->toBe(['View profile', 'Unpin', 'Pop', 'Unmute', 'Block @ada']) + ->and(nodeByRef($screen, 'unpin-conv-1')['props']['icon'] ?? null)->toBe('custom:pin-off') + ->and(nodeByRef($screen, 'pop-conv-1')['props']['icon'] ?? null)->toBe('custom:archive-restore') + ->and(nodeByRef($screen, 'unmute-conv-1')['props']['icon'] ?? null)->toBe('custom:bell'); +}); + +it('marks pinned and muted rows, and treats a mute that has run out as no mute', function () { + serveInbox([ + conversationJson(['pinned_at' => '2026-10-19T12:00:00.000000Z', 'muted_until_at' => '9999-12-31T00:00:00.000000Z']), + orgConversationJson(['muted_until_at' => '2026-10-19T15:00:00.000000Z']), + ]); + + $screen = finishRefreshes(Native::visit('/remotes')); + + expect(rendersRef($screen, 'pinned-conv-1'))->toBeTrue() + ->and(rendersRef($screen, 'muted-conv-1'))->toBeTrue() + ->and(nodeByRef($screen, 'remote-conv-1')['props']['a11y_label'])->toStartWith('Ada Lovelace, pinned, muted.') + ->and(rendersRef($screen, 'muted-org-1'))->toBeFalse() + ->and(rendersRef($screen, 'pinned-org-1'))->toBeFalse() + ->and(array_column(array_filter(menuItems($screen, 'remote-org-1'), is_array(...)), 1))->toContain('Mute'); +}); + +it('pins a row at once, moving it to the top as devnull sorts, and keeps devnull\'s answer', function () { + holdRefreshes(); + serveInbox([conversationJson(), orgConversationJson(['last_message_at' => '2026-10-19T14:00:00.000000Z'])]); + + $screen = finishRefreshes(Native::visit('/remotes')); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-conv-1', 'remote-org-1']); + + $screen->call('pin', 'org-1'); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-org-1', 'remote-conv-1']) + ->and(rendersRef($screen, 'pinned-org-1'))->toBeTrue() + ->and(kept('remotes')['data'][0]['id'])->toBe('org-1'); + + finishRefreshes($screen); + + expect(sentTo('PUT', 'remotes/orgs/org-1/pin'))->toBe(['{"pinned":true}']) + ->and(kept('remotes')['data'][0]['pinned_at'])->toBe('2026-10-19T15:30:05.000000Z'); + + $screen->call('unpin', 'org-1'); + finishRefreshes($screen); + + expect(sentTo('PUT', 'remotes/orgs/org-1/pin'))->toHaveCount(2) + ->and(childRefs($screen, 'remotes'))->toBe(['remote-conv-1', 'remote-org-1']) + ->and(rendersRef($screen, 'pinned-org-1'))->toBeFalse(); +}); + +it('keeps a pin devnull has not answered for yet over a poll that answers first', function () { + holdRefreshes(); + serveInbox([conversationJson(), orgConversationJson(['last_message_at' => '2026-10-19T14:00:00.000000Z'])]); + + $screen = finishRefreshes(Native::visit('/remotes'))->call('pin', 'org-1')->call('pollInbox'); + + // The poll answers before the pin's write: run only the last task sent, the poll. + $poll = collect($screen->bridge()->callsTo('AsyncTask.Dispatch'))->last()['params']['id']; + $screen->emitNative(AsyncTaskFinished::class, [ + 'id' => $poll, + 'result' => AsyncTaskRunner::normalizeResult(AsyncTaskRunner::invoke(unserialize(AsyncTaskTransport::readPayload($poll)))), + ]); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-org-1', 'remote-conv-1']) + ->and(rendersRef($screen, 'pinned-org-1'))->toBeTrue(); +}); + +it('puts a pin back and says so when devnull turns it down', function () { + holdRefreshes(); + serveInbox([conversationJson()]); + + $screen = finishRefreshes(Native::visit('/remotes')); + serveInbox([conversationJson()], writeStatus: 500); + + finishRefreshes($screen->call('pin', 'conv-1')) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Could not update the pin. Try again.'); + + expect(rendersRef($screen, 'pinned-conv-1'))->toBeFalse() + ->and(kept('remotes')['data'][0]['pinned_at'] ?? null)->toBeNull(); +}); + +it('asks how long to mute for, in the web\'s words, and mutes for that long', function () { + holdRefreshes(); + serveInbox([conversationJson()]); + + $screen = finishRefreshes(Native::visit('/remotes'))->call('chooseMute', 'conv-1') + ->assertSee('Mute @ada') + ->assertSee('Silence browser notifications and sound on all your devices. Messages and unread counts stay visible.') + ->assertSee('For one hour') + ->assertSee('Until tomorrow') + ->assertSee('For one week') + ->assertSee('Forever'); + + $screen->tap('mute-hour'); + + expect($screen->get('muting'))->toBeNull() + ->and(rendersRef($screen, 'muted-conv-1'))->toBeTrue(); + + finishRefreshes($screen) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Muted @ada.'); + + expect(sentTo('PUT', 'remotes/@ada/mute'))->toBe(['{"duration":"hour"}']) + ->and(kept('remotes')['data'][0]['muted_until_at'])->toBe('2026-10-19T16:30:00.000000Z'); +}); + +it('unmutes straight away from the menu', function () { + holdRefreshes(); + serveInbox([orgConversationJson(['muted_until_at' => '9999-12-31T00:00:00.000000Z'])]); + + $screen = finishRefreshes(Native::visit('/remotes'))->call('unmute', 'org-1'); + + expect(rendersRef($screen, 'muted-org-1'))->toBeFalse(); + + finishRefreshes($screen) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Unmuted Mobile app testers.'); + + expect(sentTo('PUT', 'remotes/orgs/org-1/mute'))->toBe(['{"duration":"off"}']); +}); + +it('puts a mute back and says so when devnull turns it down', function () { + holdRefreshes(); + serveInbox([conversationJson()]); + + $screen = finishRefreshes(Native::visit('/remotes')); + serveInbox([conversationJson()], writeStatus: 500); + + finishRefreshes($screen->call('chooseMute', 'conv-1')->call('muteFor', 'forever')) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Could not update the mute. Try again.'); + + expect(rendersRef($screen, 'muted-conv-1'))->toBeFalse(); +}); + +it('stashes a row out of Open, and puts it back if devnull says no', function () { + holdRefreshes(); + serveInbox([conversationJson(), orgConversationJson()]); + + $screen = finishRefreshes(Native::visit('/remotes'))->call('stash', 'conv-1'); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-org-1']); + + finishRefreshes($screen) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Stashed @ada. It pops back when someone writes.'); + + expect(sentTo('POST', 'remotes/@ada/archive'))->toHaveCount(1) + ->and(array_column(kept('remotes')['data'], 'id'))->toBe(['org-1']); + + serveInbox([orgConversationJson()], writeStatus: 500); + + finishRefreshes($screen->call('stash', 'org-1')) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Could not update the conversation. Try again.'); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-org-1']); +}); + +it('blocks the partner once you confirm, in the web\'s words, and the row goes', function () { + holdRefreshes(); + serveInbox([conversationJson(), orgConversationJson()]); + + $screen = finishRefreshes(Native::visit('/remotes'))->call('confirmBlock', 'conv-1') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Block @ada?' + && str_starts_with($params['message'], 'This conversation goes away for both of you')) + ->emitNative(ButtonPressed::class, ['index' => 0, 'label' => 'Cancel', 'id' => 'remotes-block:conv-1']); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-conv-1', 'remote-org-1']); + + $screen->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Block', 'id' => 'remotes-block:conv-1']); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-org-1']); + + finishRefreshes($screen) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => str_starts_with($params['message'], 'Blocked @ada.')); + + expect(sentTo('POST', 'users/ada/block'))->toHaveCount(1) + ->and(array_column(kept('remotes')['data'], 'id'))->toBe(['org-1']); +}); + +it('leaves an org once you confirm, in the web\'s words, and the row goes', function () { + holdRefreshes(); + serveInbox([conversationJson(), orgConversationJson()]); + + $screen = finishRefreshes(Native::visit('/remotes'))->call('confirmLeave', 'org-1') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Leave Mobile app testers?' + && str_starts_with($params['message'], 'You stop seeing the org and its history.')) + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Leave', 'id' => 'remotes-leave:org-1']); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-conv-1']); + + finishRefreshes($screen); + + expect(sentTo('DELETE', 'remotes/orgs/org-1/members/local_dev'))->toHaveCount(1) + ->and(array_column(kept('remotes')['data'], 'id'))->toBe(['conv-1']); +}); + +it('brings a row back and says so when devnull turns a leave down', function () { + holdRefreshes(); + $org = orgConversationJson(['last_message_at' => '2026-10-19T14:00:00.000000Z']); + serveInbox([conversationJson(), $org]); + + $screen = finishRefreshes(Native::visit('/remotes')); + serveInbox([conversationJson(), $org], writeStatus: 500); + + finishRefreshes($screen->call('leave', 'org-1')) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Could not leave Mobile app testers. Try again.'); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-conv-1', 'remote-org-1']); +}); + +it('opens the partner\'s profile from the menu', function () { + serveInbox([conversationJson()]); + + finishRefreshes(Native::visit('/remotes'))->call('viewProfile', 'conv-1')->assertNavigatedTo('/remotes/users/ada'); +}); + +it('reads pin and mute from the inbox payload', function () { + $conversation = Conversation::fromArray(conversationJson(['pinned_at' => '2026-10-19T12:00:00.000000Z', 'muted_until_at' => '2026-10-19T16:00:00.000000Z'])); + + expect($conversation->isPinned())->toBeTrue() + ->and($conversation->isMuted())->toBeTrue() + ->and(Conversation::fromArray(conversationJson())->isPinned())->toBeFalse() + ->and(Conversation::fromArray(conversationJson())->isMuted())->toBeFalse(); +}); + +/** + * The labels of the thread's top-bar menu. + * + * @return list + */ +function threadMenu(TestableComponent $screen): array +{ + $menu = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'top_bar_action' && ($node['props']['id'] ?? null) === 'conversation-actions'); + + return array_map(fn (array $item): string => $item['props']['label'] ?? '', $menu['children'] ?? []); +} + +/** + * Ada's thread, then devnull taking a pin or a mute, or turning every write down. + * + * @param array $thread + */ +function serveAdaThread(array $thread = [], int $writeStatus = 200): void +{ + serveApi(fn (Request $request) => match (true) { + str_ends_with(parse_url($request->url(), PHP_URL_PATH), '/remotes/@ada/messages') => Http::response(threadJson([remoteMessageJson()], ['read_through_id' => 1, ...$thread])), + $request->method() === 'GET' => Http::response(['usage' => []]), + $writeStatus !== 200 => Http::response(['message' => 'Server Error'], $writeStatus), + str_ends_with($request->url(), '/pin') => Http::response(['pinned_at' => $request['pinned'] ? '2026-10-19T15:30:05.000000Z' : null]), + str_ends_with($request->url(), '/mute') => Http::response(['muted_until_at' => $request['duration'] === 'off' ? null : '2026-10-26T15:30:00.000000Z']), + default => Http::response(null, 204), + }); +} + +it('offers Pin and Mute in the thread menu too, in the web\'s order', function () { + serveAdaThread(); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + expect(threadMenu($screen))->toBe(['View profile', 'Pin', 'Stash', 'Mute']); + + serveAdaThread(['pinned_at' => '2026-10-19T12:00:00.000000Z', 'muted_until_at' => '9999-12-31T00:00:00.000000Z']); + + expect(threadMenu(finishRefreshes(Native::visit('/remotes/conversations/ada'))))->toBe(['View profile', 'Unpin', 'Stash', 'Unmute']); +}); + +it('pins and mutes the thread, and the inbox kept for going back shows it', function () { + keep('remotes', remotesInboxJson([conversationJson()])); + serveAdaThread(); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada', data: ['conversation' => Conversation::fromArray(conversationJson())])); + + finishRefreshes($screen->call('pin')); + + expect(threadMenu($screen))->toContain('Unpin') + ->and(sentTo('PUT', 'remotes/@ada/pin'))->toBe(['{"pinned":true}']) + ->and(kept('remotes')['data'][0]['pinned_at'])->toBe('2026-10-19T15:30:05.000000Z'); + + $screen->call('chooseMute')->assertSee('Mute @ada')->tap('mute-week'); + + finishRefreshes($screen) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Muted @ada.'); + + expect(threadMenu($screen))->toContain('Unmute') + ->and(sentTo('PUT', 'remotes/@ada/mute'))->toBe(['{"duration":"week"}']) + ->and(kept('remotes')['data'][0]['muted_until_at'])->toBe('2026-10-26T15:30:00.000000Z'); +}); + +it('puts the thread\'s pin back and says so when devnull turns it down', function () { + serveAdaThread(); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + serveAdaThread(writeStatus: 500); + + finishRefreshes($screen->call('pin')) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Could not update the pin. Try again.'); + + expect(threadMenu($screen))->toContain('Pin'); +});