From 3009a4b4998efef8a38b7756cc5ed51e4949e5d0 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:42:17 +0100 Subject: [PATCH 1/5] Add Remotes: the inbox, threads and their settings, as on the web Remotes takes Saved's place in the tab bar, and Saved opens from your own profile. The inbox and threads poll as the web's do, messages push staged and then pushed, and the Account screen gains the web's Remotes switch and Share pulls. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Account.php | 53 +- app/Devnull/Client.php | 48 + app/Devnull/Data/Conversation.php | 110 +++ app/Devnull/Data/CurrentUser.php | 10 +- app/Devnull/Data/Message.php | 262 ++++++ app/Devnull/Data/Remotes.php | 66 ++ app/Devnull/Data/Thread.php | 108 +++ app/Devnull/Display.php | 8 + app/Devnull/ResponseCache.php | 15 +- app/Icons/Lucide.php | 2 + .../Concerns/RefreshesInBackground.php | 4 +- app/NativeComponents/Conversation.php | 879 ++++++++++++++++++ app/NativeComponents/Layouts/TabsLayout.php | 26 +- app/NativeComponents/Onboarding.php | 1 + app/NativeComponents/Profile.php | 49 +- app/NativeComponents/Remotes.php | 287 ++++++ app/NativeComponents/ThisDevice.php | 121 ++- resources/icons/messages-square.svg | 1 + resources/views/native/conversation.blade.php | 163 ++++ .../native/partials/message-body.blade.php | 23 + .../native/partials/message-item.blade.php | 90 ++ .../native/partials/message-status.blade.php | 19 + .../native/partials/profile-header.blade.php | 14 +- .../partials/remote-row-loading.blade.php | 13 + .../native/partials/remote-row.blade.php | 64 ++ resources/views/native/remotes.blade.php | 61 ++ resources/views/native/this-device.blade.php | 50 + routes/mobile.php | 13 +- tests/Feature/Devnull/ResponseCacheTest.php | 17 + tests/Feature/Screens/AccountRemotesTest.php | 131 +++ tests/Feature/Screens/ConversationTest.php | 279 ++++++ tests/Feature/Screens/DraftStashTest.php | 2 +- tests/Feature/Screens/ProfileRemoteTest.php | 79 ++ tests/Feature/Screens/RemotesTest.php | 181 ++++ tests/Feature/Screens/SavedTest.php | 42 +- tests/Feature/Screens/TabsTest.php | 9 +- tests/Pest.php | 104 +++ 37 files changed, 3343 insertions(+), 61 deletions(-) create mode 100644 app/Devnull/Data/Conversation.php create mode 100644 app/Devnull/Data/Message.php create mode 100644 app/Devnull/Data/Remotes.php create mode 100644 app/Devnull/Data/Thread.php create mode 100644 app/NativeComponents/Conversation.php create mode 100644 app/NativeComponents/Remotes.php create mode 100644 resources/icons/messages-square.svg create mode 100644 resources/views/native/conversation.blade.php create mode 100644 resources/views/native/partials/message-body.blade.php create mode 100644 resources/views/native/partials/message-item.blade.php create mode 100644 resources/views/native/partials/message-status.blade.php create mode 100644 resources/views/native/partials/remote-row-loading.blade.php create mode 100644 resources/views/native/partials/remote-row.blade.php create mode 100644 resources/views/native/remotes.blade.php create mode 100644 tests/Feature/Screens/AccountRemotesTest.php create mode 100644 tests/Feature/Screens/ConversationTest.php create mode 100644 tests/Feature/Screens/ProfileRemoteTest.php create mode 100644 tests/Feature/Screens/RemotesTest.php diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index 8f33e67..6b95b26 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -3,6 +3,7 @@ namespace App\Devnull; use App\Devnull\Data\Pasteis; +use App\Devnull\Data\Remotes; use App\Devnull\Data\User; use Illuminate\Support\Facades\Cache; @@ -19,6 +20,13 @@ final class Account private const string PasteisKey = 'devnull.pasteis'; + private const string RemotesKey = 'devnull.remotes'; + + /** + * What's kept while devnull has said you have Remotes off, as against not having said yet. + */ + private const string RemotesOff = 'off'; + public function user(): ?User { $attributes = Cache::get(self::CacheKey); @@ -72,8 +80,48 @@ public function rememberPasteis(?Pasteis $pasteis): void } /** - * Signs this phone out: who it was, the badge, its pastéis, every answer kept for a quick start, and your unsent - * drafts. + * Your Remotes as devnull last said, or null while you have them off. Until devnull has said, they're on, as + * they are for everyone to start with. + */ + public function remotes(): ?Remotes + { + $kept = Cache::get(self::RemotesKey); + + return match (true) { + $kept === self::RemotesOff => null, + is_array($kept) => Remotes::fromArray($kept), + default => new Remotes, + }; + } + + /** + * Remembers your Remotes, or with null that you have them off. + */ + public function rememberRemotes(?Remotes $remotes): void + { + Cache::forever(self::RemotesKey, $remotes?->toArray() ?? self::RemotesOff); + } + + /** + * How many messages and requests were waiting when devnull last said, for the Remotes tab's badge. + */ + public function unreadRemotes(): int + { + return $this->remotes()->unreadCount ?? 0; + } + + public function rememberUnreadRemotes(int $count): void + { + $remotes = $this->remotes(); + + if ($remotes !== null) { + $this->rememberRemotes($remotes->withUnreadCount($count)); + } + } + + /** + * Signs this phone out: who it was, the badges, its pastéis and Remotes, every answer kept for a quick start, + * and your unsent drafts. */ public function forget(): void { @@ -83,5 +131,6 @@ public function forget(): void Cache::forget(self::CacheKey); Cache::forget(self::UnreadKey); Cache::forget(self::PasteisKey); + Cache::forget(self::RemotesKey); } } diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 19313d0..0c1852b 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -66,8 +66,34 @@ */ public const string PasteisModePath = 'me/pasteis-mode'; + /** + * Your Remotes inbox, and where Remotes are switched on and off and their settings saved, as on the web. + */ + public const string RemotesPath = 'remotes'; + + public const string RemotesSwitchPath = 'me/remotes'; + + public const string RemotePreferencesPath = 'me/remote-preferences'; + public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} + /** + * Where a Remotes thread lives: with a person, by their username, or an org, by its id. Its messages are pushed + * to the same path, and its read, typing and stash live beside it. + */ + public static function threadPath(string $username = '', ?string $orgId = null): string + { + return $orgId === null ? 'remotes/@'.rawurlencode($username) : 'remotes/orgs/'.rawurlencode($orgId); + } + + /** + * Where a message's reactions are left and taken back. + */ + public static function reactionsPath(int $messageId): string + { + return 'remotes/messages/'.$messageId.'/reactions'; + } + /** * Where a pastel is given to a commit, one per request. */ @@ -212,6 +238,28 @@ public function saved(?string $cursor = null): Page return Page::fromArray($this->get('saved', ['cursor' => $cursor]), Commit::fromArray(...)); } + /** + * A page of your Remotes inbox, newest first, for the web's Open, Fetched + * or Stashed tab, continuing before a conversation. + * + * @return array + */ + public function remotes(?string $tab = null, ?string $before = null): array + { + return $this->get(self::RemotesPath, ['tab' => $tab, 'before' => $before]); + } + + /** + * The messages of a Remotes thread before one, as the web loads them when + * you scroll back. + * + * @return array + */ + public function olderMessages(string $threadPath, int $before): array + { + return $this->get($threadPath.'/messages', ['before' => (string) $before]); + } + /** * Reports a commit to devnull's moderators, privately, as the web's Report * button does. Nothing on the commit changes, so nothing kept changes diff --git a/app/Devnull/Data/Conversation.php b/app/Devnull/Data/Conversation.php new file mode 100644 index 0000000..52bfca1 --- /dev/null +++ b/app/Devnull/Data/Conversation.php @@ -0,0 +1,110 @@ + $members + */ + public function __construct( + public string $id, + public string $kind, + public ?string $name, + public ?User $partner, + public array $members, + public ?Message $lastMessage, + public ?CarbonImmutable $lastMessageAt, + public int $ahead = 0, + public int $partnerReadThroughId = 0, + public bool $isArchived = false, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $members = []; + + foreach (is_array($data['members'] ?? null) ? $data['members'] : [] as $member) { + if (is_array($member) && is_array($member['user'] ?? null)) { + $members[] = ['user' => User::fromArray($member['user']), 'role' => is_string($member['role'] ?? null) ? $member['role'] : null]; + } + } + + return new self( + id: (string) ($data['id'] ?? ''), + kind: ($data['kind'] ?? 'direct') === 'org' ? 'org' : 'direct', + name: is_string($data['name'] ?? null) && $data['name'] !== '' ? $data['name'] : null, + partner: is_array($data['partner'] ?? null) ? User::fromArray($data['partner']) : null, + members: $members, + lastMessage: is_array($data['last_message'] ?? null) ? Message::fromArray($data['last_message']) : null, + lastMessageAt: Commit::date($data['last_message_at'] ?? null), + 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, + ); + } + + public function isOrg(): bool + { + return $this->kind === 'org'; + } + + /** + * The everyone else in an org, in the order they joined. + * + * @return list + */ + public function others(?string $viewerId): array + { + return array_values(array_map( + fn (array $member): User => $member['user'], + array_filter($this->members, fn (array $member): bool => $member['user']->id !== $viewerId), + )); + } + + /** + * What the row and the thread are called: the partner's name, or an org's name, or else the first names of + * the others in it, as the web titles an org it hasn't named. + */ + public function title(?string $viewerId): string + { + if (! $this->isOrg()) { + return $this->partner->name ?? ''; + } + + if ($this->name !== null) { + return $this->name; + } + + $names = array_map(fn (User $user): string => (string) strtok($user->name, ' '), $this->others($viewerId)); + + return $names === [] ? 'Just you' : implode(', ', $names); + } + + /** + * Where its thread opens in the Remotes tab. + */ + public function path(): string + { + return $this->isOrg() ? '/orgs/'.rawurlencode($this->id) : '/conversations/'.rawurlencode($this->partner->username ?? ''); + } + + /** + * Whether the last message is yours and the partner has pulled it, which the row marks as the web does. + */ + public function lastPulled(): bool + { + return $this->lastMessage !== null && $this->partnerReadThroughId >= $this->lastMessage->id; + } +} diff --git a/app/Devnull/Data/CurrentUser.php b/app/Devnull/Data/CurrentUser.php index f802fae..4641f9b 100644 --- a/app/Devnull/Data/CurrentUser.php +++ b/app/Devnull/Data/CurrentUser.php @@ -13,7 +13,8 @@ * custom colour you chose (null for Automatic) and the one Automatic gives you, your GitHub * Sponsors page, and when your profile was last synced from GitHub (null when * it only was when you signed in). It has whether pastéis mode is on, how - * many pastéis you can still give today, and how many others have given you. + * many pastéis you can still give today, and how many others have given you, + * and your Remotes settings, or null while you have Remotes off. */ final readonly class CurrentUser { @@ -28,6 +29,7 @@ public function __construct( public bool $pasteisMode = false, public Pasteis $pasteisToday = new Pasteis, public int $pasteisReceived = 0, + public ?Remotes $remotes = null, ) {} /** @@ -55,6 +57,7 @@ public static function fromArray(array $json): self pasteisMode: ($data['pasteis_mode'] ?? false) === true, pasteisToday: Pasteis::fromKept(['remaining' => $data['pasteis_remaining'] ?? null, 'resets_at' => $data['pasteis_resets_at'] ?? null]), pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0, + remotes: Remotes::fromArray($data['remotes'] ?? null), ); } @@ -79,6 +82,11 @@ public function withPasteisMode(bool $pasteisMode): self return new self(...[...get_object_vars($this), 'pasteisMode' => $pasteisMode]); } + public function withRemotes(?Remotes $remotes): self + { + return new self(...[...get_object_vars($this), 'remotes' => $remotes]); + } + /** * Your GitHub Sponsors page as the web prints it, without the scheme. */ diff --git a/app/Devnull/Data/Message.php b/app/Devnull/Data/Message.php new file mode 100644 index 0000000..bb3f482 --- /dev/null +++ b/app/Devnull/Data/Message.php @@ -0,0 +1,262 @@ + $content + * @param array{id: int, user_id: string, body: string, images_count: int}|null $replyTo + * @param list $images + * @param list $reactions + */ + public function __construct( + public int $id, + public string $userId, + public string $clientId, + public string $body, + public array $content = [], + public ?int $replyToId = null, + public ?array $replyTo = null, + public ?LinkPreview $linkPreview = null, + public bool $linkPreviewPending = false, + public array $images = [], + public array $reactions = [], + public ?string $event = null, + public ?string $subjectId = null, + public ?User $user = null, + public ?User $subject = null, + public ?CarbonImmutable $createdAt = null, + public string $status = self::Pushed, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $body = is_string($data['body'] ?? null) ? $data['body'] : ''; + $replyTo = is_array($data['reply_to'] ?? null) ? $data['reply_to'] : null; + $preview = is_array($data['link_preview'] ?? null) ? $data['link_preview'] : null; + + return new self( + id: is_int($data['id'] ?? null) ? $data['id'] : 0, + userId: (string) ($data['user_id'] ?? ''), + clientId: (string) ($data['client_id'] ?? ''), + body: $body, + content: CommitBody::blocks( + $body, + is_array($data['body_segments'] ?? null) ? array_values(array_filter($data['body_segments'], is_array(...))) : null, + array_values(array_filter(array_map( + fn (mixed $mention): ?Mention => is_array($mention) ? Mention::fromArray($mention) : null, + is_array($data['mentions'] ?? null) ? $data['mentions'] : [], + ))), + ), + replyToId: is_int($data['reply_to_id'] ?? null) ? $data['reply_to_id'] : null, + replyTo: $replyTo === null ? null : [ + 'id' => (int) ($replyTo['id'] ?? 0), + 'user_id' => (string) ($replyTo['user_id'] ?? ''), + 'body' => is_string($replyTo['body'] ?? null) ? $replyTo['body'] : '', + 'images_count' => (int) ($replyTo['images_count'] ?? 0), + ], + linkPreview: $preview === null ? null : LinkPreview::fromArray($preview), + linkPreviewPending: ($preview['is_pending'] ?? false) === true, + images: array_values(array_filter(array_map( + fn (mixed $image): ?CommitImage => is_array($image) ? CommitImage::fromArray($image) : null, + is_array($data['images'] ?? null) ? $data['images'] : [], + ))), + reactions: self::reactionsFrom($data['reactions'] ?? []), + event: is_string($data['event'] ?? null) ? $data['event'] : null, + subjectId: is_string($data['subject_id'] ?? null) ? $data['subject_id'] : null, + user: is_array($data['user'] ?? null) ? User::fromArray($data['user']) : null, + subject: is_array($data['subject'] ?? null) ? User::fromArray($data['subject']) : null, + createdAt: Commit::date($data['created_at'] ?? null), + ); + } + + /** + * A message you're pushing, shown staged until devnull has it. + */ + public static function staged(string $userId, string $clientId, string $body, ?self $replyTo = null): self + { + return new self( + id: 0, + userId: $userId, + clientId: $clientId, + body: $body, + content: CommitBody::blocks($body, null), + replyToId: $replyTo?->id, + replyTo: $replyTo === null ? null : ['id' => $replyTo->id, 'user_id' => $replyTo->userId, 'body' => $replyTo->body, 'images_count' => count($replyTo->images)], + createdAt: CarbonImmutable::now(), + status: self::Staged, + ); + } + + /** + * Reactions as devnull lists them, in the order they were left. + * + * @return list + */ + public static function reactionsFrom(mixed $reactions): array + { + $kept = []; + + foreach (is_array($reactions) ? $reactions : [] as $reaction) { + if (is_array($reaction) && is_string($reaction['emoji'] ?? null) && is_string($reaction['user_id'] ?? null)) { + $kept[] = ['user_id' => $reaction['user_id'], 'emoji' => $reaction['emoji']]; + } + } + + return $kept; + } + + public function withReactions(array $reactions): self + { + return new self(...[...get_object_vars($this), 'reactions' => $reactions]); + } + + public function withStatus(string $status): self + { + return new self(...[...get_object_vars($this), 'status' => $status]); + } + + public function withLinkPreview(?LinkPreview $linkPreview, bool $pending): self + { + return new self(...[...get_object_vars($this), 'linkPreview' => $linkPreview, 'linkPreviewPending' => $pending]); + } + + public function isEvent(): bool + { + return $this->event !== null; + } + + public function isPending(): bool + { + return $this->status !== self::Pushed; + } + + /** + * The chips under the message: each emoji once, in the order it was first left, with how many left it and + * whether you're one of them. + * + * @return list + */ + public function reactionChips(?string $viewerId): array + { + $chips = []; + + foreach ($this->reactions as $reaction) { + $chips[$reaction['emoji']] ??= ['emoji' => $reaction['emoji'], 'count' => 0, 'mine' => false]; + $chips[$reaction['emoji']]['count']++; + $chips[$reaction['emoji']]['mine'] = $chips[$reaction['emoji']]['mine'] || $reaction['user_id'] === $viewerId; + } + + return array_values($chips); + } + + public function hasReacted(?string $viewerId, string $emoji): bool + { + foreach ($this->reactions as $reaction) { + if ($reaction['user_id'] === $viewerId && $reaction['emoji'] === $emoji) { + return true; + } + } + + return false; + } + + /** + * The one line a row of the inbox shows of the last message, as the web words it: "you: …" for yours, the first + * name in an org for someone else's, "sent an image" for a message that is only images, and the event for an + * org's log line. + */ + public function preview(?string $viewerId, bool $inOrg): string + { + if ($this->isEvent()) { + return $this->eventText($viewerId); + } + + $text = trim((string) preg_replace('/\s+/', ' ', $this->body)); + $text = $text === '' ? 'sent an image' : $text; + + return match (true) { + $this->userId === $viewerId => 'you: '.$text, + $inOrg && $this->user !== null => strtok($this->user->name, ' ').': '.$text, + default => $text, + }; + } + + /** + * An org event, as the web logs it between messages: who did it, and to whom. + */ + public function eventText(?string $viewerId, ?string $orgName = null): string + { + $actor = $this->userId === $viewerId ? 'you' : ($this->user === null ? 'someone' : $this->user->handle()); + $subject = $this->subjectId === $viewerId ? 'you' : ($this->subject === null ? 'someone' : $this->subject->handle()); + + return match ($this->event) { + 'created' => $this->body !== '' ? "{$actor} created {$this->body}" : "{$actor} created the org", + 'added' => "{$actor} added {$subject}", + 'removed' => "{$actor} removed {$subject}", + 'left' => "{$actor} left", + 'renamed' => $this->body !== '' ? "{$actor} renamed the org to {$this->body}" : "{$actor} cleared the org's name", + 'promoted' => "{$actor} made {$subject} a maintainer", + 'demoted' => "{$actor} made {$subject} a contributor", + 'inherited' => "{$actor} inherited maintainer", + default => $actor, + }; + } + + /** + * What the ghost of a reply shows of the message it answers: one line of its body with the code and links + * stripped, or that it was only images. + */ + public function replySnippet(): string + { + if ($this->replyTo === null) { + return ''; + } + + $text = (string) preg_replace('/```[\s\S]*?```/', ' code ', $this->replyTo['body']); + $text = trim((string) preg_replace('/\s+/', ' ', (string) preg_replace(['/`/', '#https?://#i'], '', $text))); + + if ($text !== '') { + return $text; + } + + return $this->replyTo['images_count'] > 1 ? $this->replyTo['images_count'].' images' : 'Image'; + } + + /** + * The time under a group of messages, as the web prints it. + */ + public function time(): string + { + return $this->createdAt === null ? '' : Display::clock($this->createdAt); + } +} diff --git a/app/Devnull/Data/Remotes.php b/app/Devnull/Data/Remotes.php new file mode 100644 index 0000000..90bcc1c --- /dev/null +++ b/app/Devnull/Data/Remotes.php @@ -0,0 +1,66 @@ + $this->from, + 'pull_receipts' => $this->pullReceipts, + 'requests' => $this->requests, + 'everyone' => $this->everyone, + 'unread_count' => $this->unreadCount, + ]; + } + + public function with(string $from, bool $pullReceipts): self + { + return new self($from, $pullReceipts, $this->requests, $this->everyone, $this->unreadCount); + } + + public function withUnreadCount(int $unreadCount): self + { + return new self($this->from, $this->pullReceipts, $this->requests, $this->everyone, max(0, $unreadCount)); + } +} diff --git a/app/Devnull/Data/Thread.php b/app/Devnull/Data/Thread.php new file mode 100644 index 0000000..1acf359 --- /dev/null +++ b/app/Devnull/Data/Thread.php @@ -0,0 +1,108 @@ + $messages + * @param array> $reactionChanges + * @param list $members + * @param list $typing + */ + public function __construct( + public array $messages, + public bool $hasOlder = false, + public int $reactionsVersion = 0, + public array $reactionChanges = [], + public int $readThroughId = 0, + public ?User $partner = null, + public int $partnerReadThroughId = 0, + public ?CarbonImmutable $partnerReadAt = null, + public array $typing = [], + public bool $isArchived = false, + public string $access = self::Open, + public ?string $orgId = null, + public ?string $orgName = null, + public array $members = [], + public ?string $role = null, + ) {} + + /** + * @param array $json + */ + public static function fromArray(array $json): self + { + $data = is_array($json['data'] ?? null) ? $json['data'] : []; + $changes = []; + $members = []; + + foreach (is_array($data['reaction_changes'] ?? null) ? $data['reaction_changes'] : [] as $change) { + if (is_array($change) && is_int($change['id'] ?? null)) { + $changes[$change['id']] = Message::reactionsFrom($change['reactions'] ?? []); + } + } + + foreach (is_array($data['members'] ?? null) ? $data['members'] : [] as $member) { + if (is_array($member) && is_array($member['user'] ?? null)) { + $members[] = [ + 'user' => User::fromArray($member['user']), + 'role' => is_string($member['role'] ?? null) ? $member['role'] : null, + 'read_through_id' => is_int($member['read_through_id'] ?? null) ? $member['read_through_id'] : 0, + ]; + } + } + + $partner = is_array($data['partner'] ?? null) ? User::fromArray($data['partner']) : null; + $org = is_array($data['org'] ?? null) ? $data['org'] : null; + $typing = is_array($data['typing'] ?? null) ? array_values(array_filter($data['typing'], is_string(...))) : []; + + return new self( + messages: array_values(array_map( + fn (array $message): Message => Message::fromArray($message), + array_filter(is_array($data['messages'] ?? null) ? $data['messages'] : [], is_array(...)), + )), + hasOlder: ($data['has_older'] ?? false) === true, + reactionsVersion: is_int($data['reactions_version'] ?? null) ? $data['reactions_version'] : 0, + reactionChanges: $changes, + readThroughId: is_int($data['read_through_id'] ?? null) ? $data['read_through_id'] : 0, + partner: $partner, + partnerReadThroughId: is_int($data['partner_read_through_id'] ?? null) ? $data['partner_read_through_id'] : 0, + partnerReadAt: Commit::date($data['partner_read_at'] ?? null), + typing: $partner !== null && ($data['partner_typing'] ?? false) === true ? [$partner->username] : $typing, + isArchived: ($data['is_archived'] ?? false) === true, + access: in_array($data['access'] ?? null, [self::Request, self::Waiting, self::Restricted, self::Closed], true) ? $data['access'] : self::Open, + orgId: $org === null ? null : (string) ($org['id'] ?? ''), + orgName: is_string($org['name'] ?? null) && $org['name'] !== '' ? $org['name'] : null, + members: $members, + role: is_string($data['role'] ?? null) ? $data['role'] : null, + ); + } + + public function isOrg(): bool + { + return $this->orgId !== null; + } +} diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index de251c8..0b9949d 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -115,6 +115,14 @@ public static function dayKey(CarbonInterface $date, ?CarbonInterface $now = nul return CarbonImmutable::instance($date)->setTimezone($timezone)->toDateString(); } + /** + * The time of day under a group of messages, as the web prints it, in the same time zone as dayLabel(). + */ + public static function clock(CarbonInterface $date, ?CarbonInterface $now = null): string + { + return CarbonImmutable::instance($date)->setTimezone(($now ?? CarbonImmutable::now())->getTimezone())->format('H:i'); + } + /** * The date the way `git log` prints it, offset included, since the device * time zone isn't visible to PHP. diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 63702ef..bab15d9 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -12,10 +12,11 @@ * it showed last time straight away and refresh behind it. * * Only first pages are kept, per signed-in user, as the decoded JSON devnull - * sent: never a cursor page, never the answer to a write, and nothing secret. - * An answer shows for up to seven days after it was fetched, and anything older - * counts as missing. The app's own writes patch every copy they change, so going - * back to a screen shows them at once. Signing out empties the lot (see Account). + * sent: never a cursor page or a Remotes thread's poll, never the answer to a + * write, and nothing secret. An answer shows for up to seven days after it was + * fetched, and anything older counts as missing. The app's own writes patch + * every copy they change, so going back to a screen shows them at once. + * Signing out empties the lot (see Account). * * The store lives under storage/app, which survives launches and app updates. */ @@ -46,6 +47,9 @@ final class ResponseCache '#^users/[^/]+/commits$#', '#^saved$#', '#^users/[^/]+/(followers|following)$#', + '#^remotes$#', + '#^remotes/@[^/]+/messages$#', + '#^remotes/orgs/[^/]+/messages$#', ]; /** @@ -101,7 +105,8 @@ final class ResponseCache */ public static function caches(string $path, array $query = []): bool { - if (array_key_exists('cursor', self::normalise($query))) { + // A cursor's page, and a Remotes thread's messages before or after one, aren't the first page. + if (array_intersect_key(self::normalise($query), array_flip(['cursor', 'before', 'after'])) !== []) { return false; } diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 83ec0df..1e94e7a 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -22,6 +22,7 @@ enum Lucide { case GitCommitVertical; case Bell; + case MessagesSquare; case UserRound; case Settings2; case MessageSquareReply; @@ -114,6 +115,7 @@ public function custom(bool $filled = false): ?string $name = match ($this) { self::GitCommitVertical => 'git-commit-vertical', self::Bell => 'bell', + self::MessagesSquare => 'messages-square', self::UserRound => 'user-round', self::Settings2 => 'settings-2', self::MessageSquareReply => 'message-square-reply', diff --git a/app/NativeComponents/Concerns/RefreshesInBackground.php b/app/NativeComponents/Concerns/RefreshesInBackground.php index e9cedee..310b1b5 100644 --- a/app/NativeComponents/Concerns/RefreshesInBackground.php +++ b/app/NativeComponents/Concerns/RefreshesInBackground.php @@ -128,7 +128,8 @@ protected function refresh(string $path, array $query, Closure $apply, bool $aga /** * Asks devnull how many notifications are unread, for the badge on the - * Notifications tab, and picks up any change to you while it's there. + * Notifications tab, and picks up any change to you while it's there, + * your Remotes and their unread count among them. * The badge is a nicety, so a failure here is never put on the screen. */ protected function refreshUnreadNotifications(): void @@ -139,6 +140,7 @@ protected function refreshUnreadNotifications(): void if ($current->user->username !== '') { app(Account::class)->remember($current->user); app(Account::class)->rememberPasteis($current->pasteis()); + app(Account::class)->rememberRemotes($current->remotes); } app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php new file mode 100644 index 0000000..b26120c --- /dev/null +++ b/app/NativeComponents/Conversation.php @@ -0,0 +1,879 @@ + */ + public array $messages = []; + + /** + * Messages you pushed that devnull hasn't taken yet, staged or rejected, after the rest. + * + * @var list + */ + public array $pending = []; + + public bool $hasOlder = false; + + public int $reactionsVersion = 0; + + /** + * Where you'd pulled to, and the latest message, when the thread opened, for the HEAD marker. + */ + public ?int $head = null; + + public int $headUntil = 0; + + public int $readThroughId = 0; + + public bool $loaded = false; + + public string $draft = ''; + + public ?int $replyToId = null; + + /** + * The message a long press opened the reactions sheet for. + */ + public ?int $sheetMessageId = null; + + public bool $sheetOpen = false; + + /** + * The messages whose reaction you changed and devnull hasn't answered for yet, which a poll leaves alone. + * + * @var array + */ + public array $reacting = []; + + /** + * The emoji you react with, most used first, which the sheet's quick reactions follow. + * + * @var list + */ + public array $usage = []; + + public float $lastActivityAt = 0; + + public float $lastPolledAt = 0; + + public float $lastTypingAt = 0; + + /** + * Why Merge or Drop didn't go through, shown in the request's bar. + */ + public ?string $requestError = null; + + /** + * What the inbox's row knew, to title the thread until devnull answers. + */ + public ?ConversationData $row = null; + + public function mount(): void + { + $row = $this->data('conversation'); + $this->row = $row instanceof ConversationData ? $row : null; + $this->showKept(); + $this->revalidate(); + $this->refresh('remotes/reactions', [], fn (array $json) => $this->usage = self::usageFrom($json), quiet: true); + } + + public function onResume(): void + { + $this->forgetRefreshes(); + $this->showKept(); + $this->revalidate(); + } + + public function navTitle(): string + { + return $this->title() ?: 'Remotes'; + } + + public function navigationOptions(): ?NavBarOptions + { + $options = NavBarOptions::make()->subtitle($this->subtitle()); + + if ($this->thread === null) { + return $options; + } + + $items = []; + + if (! $this->isOrg()) { + $items[] = NavAction::make('view-profile')->label('View profile')->icon(ios: Lucide::UserRound->ios(), android: Lucide::UserRound->android())->press('viewProfile'); + } + + $items[] = $this->thread->isArchived + ? NavAction::make('pop')->label('Pop')->icon(ios: Lucide::ArrowUp->ios(), android: Lucide::ArrowUp->android())->press('pop') + : NavAction::make('stash')->label('Stash')->icon(ios: Lucide::FoldVertical->ios(), android: Lucide::FoldVertical->android())->press('stash'); + + return $options->action( + NavAction::make('conversation-actions') + ->icon(ios: Lucide::Ellipsis->ios(), android: Lucide::Ellipsis->android()) + ->a11yLabel('Conversation actions') + ->items($items), + ); + } + + /** + * The web's poll, for what came after the last message and the reactions changed since. + */ + #[Poll(2000)] + public function poll(): void + { + $now = self::now(); + $active = $now - $this->lastActivityAt < self::ActiveWindow; + + if (! $this->loaded || (! $active && $now - $this->lastPolledAt < self::IdlePoll)) { + return; + } + + $this->lastPolledAt = $now; + + $this->refresh($this->messagesPath(), [ + 'after' => (string) $this->lastId(), + 'reactions_after' => (string) $this->reactionsVersion, + ], fn (array $json) => $this->takePoll($json), quiet: true); + } + + protected function revalidate(): void + { + $this->refresh($this->messagesPath(), [], fn (array $json) => $this->takeThread($json, fresh: true)); + $this->refreshUnreadNotifications(); + } + + protected function showsContent(): bool + { + return $this->loaded; + } + + /** + * Pushes what's in the composer, staged at once, as the web does. + */ + public function send(): void + { + $body = trim($this->draft); + $me = $this->me(); + + if ($body === '' || mb_strlen($body) > Message::MaxLength || $me === null || $this->thread?->access !== Thread::Open) { + return; + } + + $message = Message::staged($me->id, (string) Str::uuid(), $body, $this->findMessage($this->replyToId)); + + $this->pending[] = $message; + $this->draft = ''; + $this->replyToId = null; + $this->lastActivityAt = self::now(); + $this->lastTypingAt = 0; + + $this->push($message); + } + + /** + * Sends a rejected message again, with the same client id, so devnull stores it once however often it's sent. + */ + public function retry(string $clientId): void + { + foreach ($this->pending as $index => $message) { + if ($message->clientId === $clientId && $message->status === Message::Rejected) { + $this->pending[$index] = $message->withStatus(Message::Staged); + $this->push($this->pending[$index]); + } + } + } + + /** + * Tells the others you're typing, at most every three seconds, while the composer has something in it. + */ + public function updatedDraft(): void + { + $now = self::now(); + + if (trim($this->draft) === '' || $this->thread?->access !== Thread::Open || $now - $this->lastTypingAt < self::TypingPing || $this->lastId() === 0) { + return; + } + + $this->lastTypingAt = $now; + $this->writeInBackground('PUT', $this->path().'/typing', null, fn () => null, fn () => null); + } + + /** + * The messages before the first one showing, as the web loads them when you scroll back. + */ + public function loadOlder(): void + { + $first = $this->messages[0] ?? null; + + if (! $this->hasOlder || $first === null) { + return; + } + + $json = $this->attempt(fn (Client $devnull): array => $devnull->olderMessages($this->path(), $first->id)); + + if ($json === null) { + return; + } + + $older = Thread::fromArray($json); + $this->messages = self::merged($older->messages, $this->messages); + $this->hasOlder = $older->hasOlder; + } + + /** + * A long press on a message: its reactions and Reply, in a sheet. + */ + public function openMessage(int $id): void + { + if ($this->findMessage($id)?->isEvent() !== false) { + return; + } + + $this->sheetMessageId = $id; + $this->sheetOpen = true; + } + + public function messageSheetClosed(): void + { + $this->sheetOpen = false; + } + + public function reply(int $id): void + { + $this->replyToId = $id; + $this->sheetOpen = false; + } + + public function cancelReply(): void + { + $this->replyToId = null; + } + + /** + * A double tap toggles a heart, as on the web. + */ + public function heart(int $id): void + { + $this->toggleReaction($id, '❤️'); + } + + /** + * Leaves the emoji on the message, or takes it back, at once. If devnull says no, it goes back as it was. + */ + public function toggleReaction(int $id, string $emoji): void + { + $message = $this->findMessage($id); + $me = $this->me(); + $this->sheetOpen = false; + + if ($message === null || $message->isPending() || $message->isEvent() || $me === null || $this->thread?->access !== Thread::Open) { + return; + } + + $reacted = $message->hasReacted($me->id, $emoji); + $reactions = $reacted + ? array_values(array_filter($message->reactions, fn (array $reaction): bool => ! ($reaction['user_id'] === $me->id && $reaction['emoji'] === $emoji))) + : [...$message->reactions, ['user_id' => $me->id, 'emoji' => $emoji]]; + + $this->replaceMessage($message->withReactions($reactions)); + $this->reacting[$id] = true; + $this->lastActivityAt = self::now(); + + $this->writeInBackground($reacted ? 'DELETE' : 'POST', Client::reactionsPath($id), ['emoji' => $emoji], function (array $json) use ($id): void { + unset($this->reacting[$id]); + + if (is_array($json['reactions'] ?? null) && ($message = $this->findMessage($id)) !== null) { + $this->replaceMessage($message->withReactions(Message::reactionsFrom($json['reactions']))); + } + }, function () use ($id, $message): void { + unset($this->reacting[$id]); + $this->replaceMessage($message); + }); + } + + /** + * Merges their request, so you can reply. Done, the composer takes the bar's place. + */ + public function merge(): void + { + $this->answerRequest('merge'); + } + + /** + * Drops their request, without telling them, and goes back to the inbox. + */ + public function drop(): void + { + $this->answerRequest('drop'); + } + + public function stash(): void + { + $this->writeInBackground('POST', $this->path().'/archive', null, fn () => $this->back(), fn () => null); + } + + public function pop(): void + { + $this->writeInBackground('DELETE', $this->path().'/archive', null, function (): void { + $this->thread = $this->thread === null ? null : new Thread(...[...get_object_vars($this->thread), 'isArchived' => false]); + }, fn () => null); + } + + public function viewProfile(): void + { + if ($this->username !== '') { + $this->openInTab('/users/'.rawurlencode($this->username), $this->thread?->partner === null ? [] : ['user' => $this->thread->partner]); + } + } + + public function openProfile(string $username): void + { + $this->openInTab('/users/'.rawurlencode($username)); + } + + public function openLink(int $id): void + { + $url = $this->findMessage($id)?->linkPreview?->url; + + if ($url !== null) { + Browser::inApp($url); + } + } + + private function answerRequest(string $answer): void + { + $this->requestError = null; + + $this->writeInBackground('POST', $this->path().'/'.$answer, null, function () use ($answer): void { + if ($answer === 'drop') { + $this->back(); + + return; + } + + $this->thread = $this->thread === null ? null : new Thread(...[...get_object_vars($this->thread), 'access' => Thread::Open]); + }, function (): void { + $this->requestError = 'Could not update the request. Try again.'; + }); + } + + private function push(Message $message): void + { + $payload = ['body' => $message->body, 'client_id' => $message->clientId]; + + if ($message->replyToId !== null) { + $payload['reply_to_id'] = $message->replyToId; + } + + $this->writeInBackground('POST', $this->messagesPath(), $payload, function (array $json) use ($message): void { + $pushed = Message::fromArray(is_array($json['data'] ?? null) ? $json['data'] : []); + + $this->pending = array_values(array_filter($this->pending, fn (Message $pending): bool => $pending->clientId !== $message->clientId)); + + if ($pushed->id > 0) { + $this->messages = self::merged($this->messages, [$pushed]); + $this->readThroughId = max($this->readThroughId, $pushed->id); + } + + $this->lastActivityAt = self::now(); + }, function () use ($message): void { + $this->pending = array_map( + fn (Message $pending): Message => $pending->clientId === $message->clientId ? $pending->withStatus(Message::Rejected) : $pending, + $this->pending, + ); + }); + } + + /** + * The thread's first page, kept or fresh. The first fresh one notes where you'd pulled to for HEAD, before this + * screen pulls what's showing. Messages loaded before the page stay above it. + * + * @param array $json + */ + private function takeThread(array $json, bool $fresh = false): void + { + $thread = Thread::fromArray($json); + + if ($thread->partner === null && ! $thread->isOrg()) { + return; + } + + $earliest = $thread->messages[0]->id ?? PHP_INT_MAX; + $older = array_values(array_filter($this->messages, fn (Message $message): bool => $message->id < $earliest)); + + $this->thread = $thread; + $this->messages = self::merged($older, $thread->messages); + $this->hasOlder = $older === [] ? $thread->hasOlder : $this->hasOlder; + $this->reactionsVersion = max($this->reactionsVersion, $thread->reactionsVersion); + $this->readThroughId = max($this->readThroughId, $thread->readThroughId); + $this->dropPushed(); + + $this->loaded = true; + + if (! $fresh) { + return; + } + + if ($this->head === null) { + $this->head = $thread->readThroughId; + $this->headUntil = $this->lastId(); + } + + $this->markRead(); + } + + /** + * What a poll brought: messages after the last one, reactions changed since the version seen, and the rest of the + * thread as it is now. New messages, or someone typing, keep the poll quick for another minute. + * + * @param array $json + */ + private function takePoll(array $json): void + { + $poll = Thread::fromArray($json); + $last = $this->lastId(); + $new = array_values(array_filter($poll->messages, fn (Message $message): bool => $message->id > $last)); + + foreach ($poll->reactionChanges as $id => $reactions) { + if (! isset($this->reacting[$id]) && ($message = $this->findMessage($id)) !== null) { + $this->replaceMessage($message->withReactions($reactions)); + } + } + + $this->thread = new Thread(...[...get_object_vars($poll), 'messages' => [], 'reactionChanges' => [], 'hasOlder' => $this->hasOlder]); + $this->messages = self::merged($this->messages, $new); + $this->reactionsVersion = max($this->reactionsVersion, $poll->reactionsVersion); + $this->dropPushed(); + + if ($new !== [] || $poll->typing !== []) { + $this->lastActivityAt = self::now(); + } + + $this->markRead(); + } + + /** + * Pulls through the latest message, since the thread is on screen, and asks devnull for the badge again after. + */ + private function markRead(): void + { + $latest = $this->lastId(); + + if ($latest <= $this->readThroughId) { + return; + } + + $this->readThroughId = $latest; + + $this->writeInBackground('PUT', $this->path().'/read', ['through' => $latest], fn () => $this->refreshUnreadNotifications(), fn () => null); + } + + /** + * Staged messages devnull has now sent back, by client id, go: the real one shows in their place. + */ + private function dropPushed(): void + { + $sent = array_flip(array_map(fn (Message $message): string => $message->clientId, $this->messages)); + + $this->pending = array_values(array_filter($this->pending, fn (Message $pending): bool => ! isset($sent[$pending->clientId]))); + } + + private function showKept(): void + { + if (($json = $this->cached($this->messagesPath())) !== null) { + $this->takeThread($json); + } + } + + private function path(): string + { + return Client::threadPath($this->username, $this->org === '' ? null : $this->org); + } + + private function messagesPath(): string + { + return $this->path().'/messages'; + } + + private function isOrg(): bool + { + return $this->org !== ''; + } + + private function lastId(): int + { + return $this->messages[array_key_last($this->messages) ?? -1]->id ?? 0; + } + + private function findMessage(?int $id): ?Message + { + foreach ($this->messages as $message) { + if ($message->id === $id) { + return $message; + } + } + + return null; + } + + private function replaceMessage(Message $replacement): void + { + $this->messages = array_map(fn (Message $message): Message => $message->id === $replacement->id ? $replacement : $message, $this->messages); + } + + /** + * Now, to the microsecond, as the clock the poll and the typing pings are timed by. + */ + private static function now(): float + { + return (float) CarbonImmutable::now()->format('U.u'); + } + + /** + * Two lists of messages as one, oldest first, the later list winning where both have a message. + * + * @param list $first + * @param list $second + * @return list + */ + private static function merged(array $first, array $second): array + { + $byId = []; + + foreach ([...$first, ...$second] as $message) { + $byId[$message->id] = $message; + } + + ksort($byId); + + return array_values($byId); + } + + /** + * @param array $json + * @return list + */ + private static function usageFrom(array $json): array + { + $usage = []; + + foreach (is_array($json['usage'] ?? null) ? $json['usage'] : [] as $used) { + if (is_array($used) && is_string($used['emoji'] ?? null) && is_int($used['count'] ?? null)) { + $usage[] = ['emoji' => $used['emoji'], 'count' => $used['count']]; + } + } + + return $usage; + } + + /** + * The quick reactions: the web's three, where one you use at least three times more than a slot's own takes + * that slot, in place. + * + * @return list + */ + private function quickReactions(): array + { + $counts = array_column($this->usage, 'count', 'emoji'); + $slots = self::QuickReactions; + + foreach ($this->usage as $used) { + if (in_array($used['emoji'], $slots, true)) { + continue; + } + + $weakest = array_reduce(array_keys($slots), fn (?int $carry, int $slot): int => $carry === null || ($counts[$slots[$slot]] ?? 0) < ($counts[$slots[$carry]] ?? 0) ? $slot : $carry); + + if ($used['count'] >= ($counts[$slots[$weakest]] ?? 0) + 3) { + $slots[$weakest] = $used['emoji']; + } + } + + return $slots; + } + + private function title(): string + { + $viewerId = $this->me()?->id; + + if ($this->thread === null) { + return $this->row?->title($viewerId) ?? ($this->isOrg() ? '' : '@'.$this->username); + } + + if (! $this->isOrg()) { + return $this->thread->partner->name ?? '@'.$this->username; + } + + $others = array_values(array_filter($this->thread->members, fn (array $member): bool => $member['user']->id !== $viewerId)); + $names = array_map(fn (array $member): string => (string) strtok($member['user']->name, ' '), $others); + + return $this->thread->orgName ?? ($names === [] ? 'Just you' : implode(', ', $names)); + } + + private function subtitle(): ?string + { + if (! $this->isOrg()) { + return '@'.$this->username; + } + + $count = $this->thread === null ? count($this->row->members ?? []) : count($this->thread->members); + + return $count === 0 ? 'org' : 'org · '.Display::count($count, 'member', 'members'); + } + + public function render(): View + { + $viewer = $this->me(); + $thread = $this->thread; + + $authors = []; + + foreach ([$viewer, $thread?->partner, ...array_column($thread->members ?? [], 'user')] as $person) { + if ($person !== null) { + $authors[$person->id] = $person; + } + } + + return view('native.conversation', [ + 'viewer' => $viewer, + 'authors' => $authors, + 'myColour' => $this->myColour(), + 'partner' => $thread?->partner, + 'isOrg' => $this->isOrg(), + 'access' => $thread->access ?? Thread::Open, + 'items' => $this->items($viewer), + 'typing' => self::typingText($thread?->typing ?? [], $this->isOrg()), + 'replyTo' => $this->findMessage($this->replyToId), + 'sheetMessage' => $this->findMessage($this->sheetMessageId), + 'quickReactions' => $this->quickReactions(), + 'moreReactions' => self::MoreReactions, + 'canPush' => $thread !== null && $thread->access === Thread::Open, + 'placeholder' => $this->isOrg() ? 'Message '.($thread->orgName ?? 'the org') : 'Message @'.$this->username, + ]); + } + + /** + * The web's typing line: one person by handle, two by both, more by how many. + * + * @param list $usernames + */ + private static function typingText(array $usernames, bool $isOrg): ?string + { + return match (count($usernames)) { + 0 => null, + 1 => '@'.$usernames[0].' is typing…', + 2 => '@'.$usernames[0].' and @'.$usernames[1].' are typing…', + default => count($usernames).' people are typing…', + }; + } + + /** + * What the thread shows, top to bottom: a day marker where the day changes, HEAD before the first message you + * hadn't pulled when it opened, an org's events as log lines, and each message with whether it starts or ends a + * run of messages by the same person, which is where the web puts the author and the time. + * + * @return list> + */ + private function items(?User $viewer): array + { + $all = [...$this->messages, ...$this->pending]; + $items = []; + $day = null; + $headShown = false; + $status = $this->statusMessage($all, $viewer); + + foreach ($all as $index => $message) { + $label = $message->createdAt === null ? null : Display::dayLabel($message->createdAt); + + if ($label !== null && $label !== $day) { + $items[] = ['type' => 'day', 'label' => $label]; + $day = $label; + } + + $isNew = ! $headShown && $index > 0 && $this->head !== null && ! $message->isPending() && $message->id > $this->head && $message->id <= $this->headUntil && $message->userId !== $viewer?->id && ! $message->isEvent(); + + if ($isNew) { + $headShown = true; + $items[] = ['type' => 'head', 'count' => count(array_filter($all, fn (Message $later): bool => $later->id > $this->head && $later->id <= $this->headUntil && $later->userId !== $viewer?->id && ! $later->isEvent()))]; + } + + if ($message->isEvent()) { + $items[] = ['type' => 'event', 'message' => $message, 'text' => $message->eventText($viewer?->id)]; + + continue; + } + + $previous = $items[array_key_last($items) ?? -1] ?? null; + $startsRun = ($previous['type'] ?? null) !== 'message' || $previous['message']->userId !== $message->userId; + + if (! $startsRun) { + $items[array_key_last($items)]['endsRun'] = false; + } + + $items[] = [ + 'type' => 'message', + 'message' => $message, + 'mine' => $message->userId === $viewer?->id, + 'startsRun' => $startsRun, + 'endsRun' => true, + 'status' => $message === $status ? $this->statusOf($message) : null, + 'author' => $message->user ?? $this->authorOf($message->userId), + ]; + } + + return $items; + } + + /** + * The message whose status the thread shows, as the web picks it: the latest one you're still pushing, or else + * your latest message, or in an org the latest message at all. + * + * @param list $all + */ + private function statusMessage(array $all, ?User $viewer): ?Message + { + if ($this->pending !== []) { + return $this->pending[array_key_last($this->pending)]; + } + + foreach (array_reverse($all) as $message) { + if (! $message->isEvent() && ($this->isOrg() || $message->userId === $viewer?->id)) { + return $message; + } + } + + return null; + } + + /** + * What the status line says under the message: staged, pushed or pulled (with when, with one person), or who + * in an org has pulled it. + * + * @return array{state: string, text: string} + */ + private function statusOf(Message $message): array + { + if ($message->isPending()) { + return ['state' => $message->status, 'text' => $message->status]; + } + + if ($this->isOrg()) { + $pulled = array_values(array_filter($this->thread->members ?? [], fn (array $member): bool => $member['user']->id !== $message->userId && $member['read_through_id'] >= $message->id)); + + return $pulled === [] + ? ['state' => 'pushed', 'text' => $message->time().' · pushed'] + : ['state' => 'pulled', 'text' => $message->time().' · pulled by '.implode(', ', array_map(fn (array $member): string => $member['user']->handle(), array_slice($pulled, 0, 5))).(count($pulled) > 5 ? ' +'.(count($pulled) - 5) : '')]; + } + + $pulledAt = $this->thread?->partnerReadAt; + + return ($this->thread->partnerReadThroughId ?? 0) >= $message->id + ? ['state' => 'pulled', 'text' => 'pulled'.($pulledAt === null ? '' : ' '.Display::clock($pulledAt))] + : ['state' => 'pushed', 'text' => 'pushed']; + } + + private function authorOf(string $userId): ?User + { + if ($this->thread?->partner?->id === $userId) { + return $this->thread->partner; + } + + foreach ($this->thread->members ?? [] as $member) { + if ($member['user']->id === $userId) { + return $member['user']; + } + } + + return $userId === $this->me()?->id ? $this->me() : null; + } +} diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 525518b..57394ad 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -14,17 +14,17 @@ use Native\Mobile\Edge\NativeComponent; /** - * The top-level sections: Log, Notifications, Saved and your Profile, with the - * icons of the web's nav, picked out in the colour you chose for your lane. - * Notifications wears a badge with the unread count devnull last gave. The - * web's phone keeps Saved in its account menu; here it's a tab of its own. + * The top-level sections: Log, Notifications, Remotes and your Profile, with + * the icons of the web's nav, picked out in the colour you chose for your lane. + * Notifications and Remotes wear a badge with the unread count devnull last + * gave. Saved opens from your own profile. */ class TabsLayout extends NativeLayout { /** * Each tab's URI. Commits and profiles open beneath the one they were tapped in. */ - public const array Tabs = ['/log', '/notifications', '/saved', '/profile']; + public const array Tabs = ['/log', '/notifications', '/remotes', '/profile']; /** * The icon and "/dev/null" wordmark as the web's MobileHeader draws them, 105 x 28pt. @@ -71,21 +71,27 @@ public function navBar(NativeComponent $screen): ?NavBar public function tabBar(NativeComponent $screen): ?TabBar { $notifications = Tab::link('Notifications', '/notifications', ios: Lucide::Bell->ios(), android: Lucide::Bell->android()); - $unread = app(Account::class)->unreadNotifications(); + $remotes = Tab::link('Remotes', '/remotes', ios: Lucide::MessagesSquare->ios(), android: Lucide::MessagesSquare->android()); - if ($unread > 0) { - $notifications->badge($unread > 99 ? '99+' : (string) $unread); - } + self::badge($notifications, app(Account::class)->unreadNotifications()); + self::badge($remotes, app(Account::class)->unreadRemotes()); return TabBar::make() ->activeColor(self::accent()) ->labelVisibility('labeled') ->add(Tab::link('Log', '/log', ios: Lucide::GitCommitVertical->ios(), android: Lucide::GitCommitVertical->android())) ->add($notifications) - ->add(Tab::link('Saved', '/saved', ios: Lucide::Bookmark->ios(), android: Lucide::Bookmark->android())) + ->add($remotes) ->add(Tab::link('Profile', '/profile', ios: Lucide::UserRound->ios(), android: Lucide::UserRound->android())); } + private static function badge(Tab $tab, int $unread): void + { + if ($unread > 0) { + $tab->badge($unread > 99 ? '99+' : (string) $unread); + } + } + /** * Your lane's colour, in its light or dark lift, or your custom colour, * or Commit Blue before devnull has said who you are. diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php index b799331..500fbcc 100644 --- a/app/NativeComponents/Onboarding.php +++ b/app/NativeComponents/Onboarding.php @@ -253,6 +253,7 @@ private function signInWithExistingKey(): void app(Account::class)->remember($current->user); app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); app(Account::class)->rememberPasteis($current->pasteis()); + app(Account::class)->rememberRemotes($current->remotes); $this->replace('/log'); } diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index 1eb848a..171eae5 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -161,12 +161,19 @@ public function navTitle(): string public function navigationOptions(): ?NavBarOptions { 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('saved') + ->icon(ios: Lucide::Bookmark->ios(), android: Lucide::Bookmark->android()) + ->a11yLabel('Saved') + ->press('openSaved'), + ) + ->action( + NavAction::make('account') + ->icon(ios: Lucide::Settings2->ios(), android: Lucide::Settings2->android()) + ->a11yLabel('Account') + ->press('openAccount'), + ); } $items = $this->safetyActions(); @@ -478,6 +485,35 @@ public function openAccount(): void $this->navigate('/profile/device'); } + /** + * The commits you saved, which the web's phone keeps in its account menu. + */ + public function openSaved(): void + { + $this->navigate('/profile/saved'); + } + + /** + * Your Remotes thread with them, empty until one of you pushes, as the web's Remote button opens it. + */ + public function openRemote(): void + { + if ($this->canRemote()) { + $this->openInTab('/conversations/'.rawurlencode($this->username), $this->profile === null ? [] : ['user' => $this->profile->user]); + } + } + + /** + * Whether the web would offer Remote here: you have Remotes on, it isn't you or someone across a block, and they + * follow you, unless devnull takes requests from anyone. Whether they have Remotes on only shows in the thread. + */ + private function canRemote(): bool + { + $remotes = app(Account::class)->remotes(); + + return $remotes !== null && $this->canChangeSafety() && ($this->profile?->followsYou === true || $remotes->requests); + } + public function editOnGitHub(): void { Browser::inApp('https://github.com/settings/profile'); @@ -720,6 +756,7 @@ public function render(): View 'myColour' => $this->myColour(), 'isBlocked' => $this->isBlocking(), 'isMe' => $this->isMe(), + 'canRemote' => $this->canRemote(), 'newCommitsLabel' => $this->newCommitsLabel(), 'viewer' => $this->me(), 'stashed' => app(Stash::class)->names($this->me()?->id), diff --git a/app/NativeComponents/Remotes.php b/app/NativeComponents/Remotes.php new file mode 100644 index 0000000..f0144ad --- /dev/null +++ b/app/NativeComponents/Remotes.php @@ -0,0 +1,287 @@ + 'Open', 'fetched' => 'Fetched', 'stashed' => 'Stashed']; + + /** + * 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. + */ + protected bool $forceFullFrames = true; + + /** @var list */ + public array $conversations = []; + + public bool $hasMore = false; + + /** + * Whether the tab has had conversations to show, kept or fresh. Until then it shows skeleton rows. + */ + public bool $loaded = false; + + public string $tab = 'open'; + + /** + * How many requests wait in Fetched, which its tab counts, as the web's does. + */ + public int $requestsCount = 0; + + /** + * You have Remotes off, as devnull or your account last said. + */ + public bool $off = false; + + public function mount(): void + { + $this->off = app(Account::class)->remotes() === null; + $this->showKept(); + $this->revalidate(); + } + + /** + * Back from a thread, where you may have pulled, pushed or stashed: take what's kept, then ask devnull. + */ + public function onResume(): void + { + $this->forgetRefreshes(); + $this->off = app(Account::class)->remotes() === null; + $this->showKept(); + $this->revalidate(); + } + + public function navTitle(): string + { + return 'Remotes'; + } + + /** + * The web's inbox poll, every 15 seconds. A poll that fails says nothing; the next one tries again. + */ + #[Poll(15000)] + public function pollInbox(): void + { + $this->refresh(Client::RemotesPath, $this->query(), fn (array $json) => $this->takeFresh($json), quiet: true); + } + + /** + * Pull to refresh. + */ + public function load(): void + { + $this->refresh(Client::RemotesPath, $this->query(), fn (array $json) => $this->takeFresh($json), again: true); + $this->refreshUnreadNotifications(); + } + + protected function revalidate(): void + { + $this->refresh(Client::RemotesPath, $this->query(), fn (array $json) => $this->takeFresh($json)); + $this->refreshUnreadNotifications(); + } + + protected function showsContent(): bool + { + return $this->loaded; + } + + /** + * devnull answers a 404 while you have Remotes off, as the web does. + */ + protected function refused(ApiException $exception): bool + { + if ($exception->status !== 404) { + return false; + } + + $this->off = true; + app(Account::class)->rememberRemotes(null); + + return true; + } + + public function showTab(string $tab): void + { + if (! array_key_exists($tab, $this->tabs()) || $tab === $this->tab) { + return; + } + + $this->tab = $tab; + $this->conversations = []; + $this->hasMore = false; + $this->loaded = false; + $this->showKept(); + $this->revalidate(); + } + + public function loadMore(): void + { + $last = $this->conversations[array_key_last($this->conversations) ?? -1] ?? null; + + if (! $this->hasMore || $last === null) { + return; + } + + $json = $this->attempt(fn (Client $devnull): array => $devnull->remotes($this->query()['tab'] ?? null, $last->id)); + + if ($json === null) { + return; + } + + $shown = array_flip(array_map(fn (ConversationData $conversation): string => $conversation->id, $this->conversations)); + + $this->conversations = [...$this->conversations, ...array_values(array_filter( + self::conversationsFrom($json), + fn (ConversationData $conversation): bool => ! isset($shown[$conversation->id]), + ))]; + $this->hasMore = ($json['has_more'] ?? false) === true; + } + + /** + * Opens a conversation's thread in this tab, titled as its row is until devnull answers. + */ + public function openConversation(string $id): void + { + foreach ($this->conversations as $conversation) { + if ($conversation->id === $id) { + $this->openInTab($conversation->path(), ['conversation' => $conversation]); + + return; + } + } + } + + /** + * The 404's way out, as the web's: back to your log. + */ + public function backToLog(): void + { + $this->replace(TabsLayout::Tabs[0])->transition(Transition::None); + } + + /** + * @return array + */ + private function tabs(): array + { + $tabs = self::Tabs; + + if (! (app(Account::class)->remotes()->requests ?? false)) { + unset($tabs['fetched']); + } + + return $tabs; + } + + /** + * Open is the inbox itself, so it's asked for without a tab, as the web's links leave it off. + * + * @return array + */ + private function query(): array + { + return $this->tab === 'open' ? [] : ['tab' => $this->tab]; + } + + private function showKept(): void + { + if (! $this->off && ($json = $this->cached(Client::RemotesPath, $this->query())) !== null) { + $this->takeFresh($json); + } + } + + /** + * A fresh first page replaces the top of the list. The conversations loaded below it stay, unless the fresh page + * has them, as the web keeps its older rows under a polled first page. The unread count goes to the tab's badge. + * + * @param array $json + */ + private function takeFresh(array $json): void + { + $fresh = self::conversationsFrom($json); + $freshIds = array_flip(array_map(fn (ConversationData $conversation): string => $conversation->id, $fresh)); + $oldest = $fresh[array_key_last($fresh) ?? -1]->lastMessageAt ?? null; + + $later = $oldest === null || ! $this->hasMore ? [] : array_values(array_filter( + $this->conversations, + fn (ConversationData $conversation): bool => ! isset($freshIds[$conversation->id]) && $conversation->lastMessageAt !== null && $conversation->lastMessageAt <= $oldest, + )); + + $this->conversations = [...$fresh, ...$later]; + $this->hasMore = $later === [] ? ($json['has_more'] ?? false) === true : $this->hasMore; + $this->requestsCount = is_int($json['requests_count'] ?? null) ? $json['requests_count'] : 0; + $this->loaded = true; + $this->off = false; + + if (is_int($json['unread_count'] ?? null)) { + app(Account::class)->rememberUnreadRemotes($json['unread_count']); + } + } + + /** + * @param array $json + * @return list + */ + private static function conversationsFrom(array $json): array + { + return array_values(array_map( + fn (array $conversation): ConversationData => ConversationData::fromArray($conversation), + array_filter(is_array($json['data'] ?? null) ? $json['data'] : [], is_array(...)), + )); + } + + public function render(): View + { + $viewer = $this->me(); + + return view('native.remotes', [ + 'viewer' => $viewer, + 'myColour' => $this->myColour(), + 'tabs' => $this->tabs(), + 'rows' => array_map(fn (ConversationData $conversation): array => [ + 'conversation' => $conversation, + 'title' => $conversation->title($viewer?->id), + 'others' => $conversation->others($viewer?->id), + 'preview' => $conversation->lastMessage?->preview($viewer?->id, $conversation->isOrg()) ?? '', + 'mine' => $conversation->lastMessage !== null && $conversation->lastMessage->userId === $viewer?->id, + ], $this->conversations), + ]); + } +} diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index e47e324..6b7a8b4 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -6,6 +6,7 @@ use App\Devnull\AppleSignIn; use App\Devnull\Client; use App\Devnull\Data\CurrentUser; +use App\Devnull\Data\Remotes; use App\Devnull\DeviceKey; use App\Devnull\Display; use App\Devnull\Exceptions\KeychainUnavailable; @@ -27,7 +28,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, pastéis mode, the people you mute and block, then this phone's + * colour, pastéis mode, Remotes and their settings, 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. * @@ -109,6 +111,21 @@ class ThisDevice extends NativeComponent */ public const string PasteisModeFailed = 'Could not save pastéis mode. Please try again.'; + public bool $savingRemotes = false; + + public ?string $remotesError = null; + + public bool $savingRemotePreferences = false; + + public ?string $remotePreferencesError = null; + + /** + * What the web says when the Remotes switch, or the settings on its Remotes page, couldn't be saved. + */ + public const string RemotesFailed = 'Could not save remotes. Please try again.'; + + public const string RemotePreferencesFailed = 'Could not save your remote settings. Try again.'; + /** * Whether devnull has an Apple ID linked to you, or null until it says. */ @@ -332,6 +349,97 @@ private function savePasteisMode(bool $enabled): void }); } + /** + * Switches Remotes, as the web's switch does. The switch and the Remotes + * tab take it at once, and devnull is told from the background; flipped + * again before devnull answers, it's saved again once it has, so the last + * flip wins. When devnull says no, it goes back, saying so in the web's words. + */ + public function setRemotes(bool $enabled): void + { + if ($this->current === null || ($this->current->remotes !== null) === $enabled) { + return; + } + + $this->remotesError = null; + $this->showRemotes($enabled ? new Remotes : null); + + if (! $this->savingRemotes) { + $this->saveRemotes($enabled); + } + } + + private function saveRemotes(bool $enabled): void + { + $this->savingRemotes = true; + + $this->writeInBackground('PUT', Client::RemotesSwitchPath, ['enabled' => $enabled], function (array $json) use ($enabled): void { + $this->savingRemotes = false; + + if ($this->current !== null && ($this->current->remotes !== null) !== $enabled) { + $this->saveRemotes($this->current->remotes !== null); + + return; + } + + $this->takeMe($json); + }, function () use ($enabled): void { + $this->savingRemotes = false; + $this->remotesError = self::RemotesFailed; + $this->showRemotes($enabled ? null : new Remotes); + }); + } + + /** + * Share pulls, as the web's checkbox: whether others see when you pull their messages, and you theirs. + */ + public function setPullReceipts(bool $pullReceipts): void + { + $remotes = $this->current?->remotes; + + if ($remotes !== null && $remotes->pullReceipts !== $pullReceipts) { + $this->saveRemotePreferences($remotes->with($remotes->from, $pullReceipts), $remotes); + } + } + + /** + * Who can push to you straight away, which the web only offers while devnull lets people pick everyone. + */ + public function setRemotesFrom(string $from): void + { + $remotes = $this->current?->remotes; + + if ($remotes !== null && $remotes->everyone && in_array($from, [Remotes::Following, Remotes::Everyone], true) && $remotes->from !== $from) { + $this->saveRemotePreferences($remotes->with($from, $remotes->pullReceipts), $remotes); + } + } + + private function saveRemotePreferences(Remotes $remotes, Remotes $before): void + { + $this->remotePreferencesError = null; + $this->savingRemotePreferences = true; + $this->showRemotes($remotes); + + $this->writeInBackground('PUT', Client::RemotePreferencesPath, ['from' => $remotes->from, 'pull_receipts' => $remotes->pullReceipts], function (array $json): void { + $this->savingRemotePreferences = false; + $this->takeMe($json); + }, function () use ($before): void { + $this->savingRemotePreferences = false; + $this->remotePreferencesError = self::RemotePreferencesFailed; + $this->showRemotes($before); + }); + } + + private function showRemotes(?Remotes $remotes): void + { + if ($this->current === null) { + return; + } + + $this->current = $this->current->withRemotes($remotes); + app(Account::class)->rememberRemotes($remotes); + } + /** * The devices page on dev-null.sh, where this phone is listed once approved. */ @@ -461,6 +569,7 @@ public function render(): View 'lanes' => $current === null ? [] : $this->laneOptions($current), 'laneChanged' => $current !== null && $this->chosenLane !== $current->preferredLane, 'pasteisMode' => $current?->pasteisMode ?? false, + 'remotes' => $current?->remotes, 'remote' => self::remote(), 'offersApple' => AppleSignIn::isAvailable(), 'appleStatus' => match ($this->appleLinked) { @@ -544,8 +653,9 @@ private function customColour(): ?string /** * Shows you as devnull sent you. A colour picked and not saved yet stays * picked; otherwise the picker follows the one devnull has. While the - * pastéis switch is saving, it keeps what it shows. Whether Apple is - * linked only comes with `GET /me`, so it's left to the refresh. + * pastéis or Remotes switches are saving, they keep what they show. + * Whether Apple is linked only comes with `GET /me`, so it's left to the + * refresh. * * @param array $json */ @@ -565,6 +675,10 @@ private function showMe(array $json): ?CurrentUser $current = $current->withPasteisMode($this->current->pasteisMode); } + if (($this->savingRemotes || $this->savingRemotePreferences) && $this->current !== null) { + $current = $current->withRemotes($this->current->remotes); + } + return $this->current = $current; } @@ -583,6 +697,7 @@ private function takeMe(array $json): void app(Account::class)->remember($current->user); app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); app(Account::class)->rememberPasteis($current->pasteis()); + app(Account::class)->rememberRemotes($current->remotes); app(ResponseCache::class)->meChanged($json); } diff --git a/resources/icons/messages-square.svg b/resources/icons/messages-square.svg new file mode 100644 index 0000000..22a7384 --- /dev/null +++ b/resources/icons/messages-square.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php new file mode 100644 index 0000000..133c6e9 --- /dev/null +++ b/resources/views/native/conversation.blade.php @@ -0,0 +1,163 @@ +{{-- + A Remotes thread: the messages, oldest first, anchored to the latest at the bottom as a chat is, then the + composer in the bottom bar. The bar keeps one line above the field for who's typing, empty or not, so the field + never moves when someone starts or stops. While the thread can't take a message, the bar says why instead, in + the web's words, with Merge and Drop for a request. +--}} +@php($composerHeight = \App\Devnull\Display::replyBarClearance($draft) + 24 + ($replyTo ? 44 : 0)) + + @include('native.partials.error') + @include('native.partials.refresh-failed') + + + + @if ($hasOlder) + + ↑ earlier messages + + @endif + + @if (! $loaded && $error === null) + + + + @endif + + @if ($loaded && $items === [] && $canPush) + + {{ $isOrg ? 'empty history. say hi to the org' : 'empty history. say hi to @'.$username }} + + @endif + + @foreach ($items as $item) + @if ($item['type'] === 'day') + + + + {{ $item['label'] }} + + + @elseif ($item['type'] === 'head') + + + HEAD · {{ $item['count'] }} new + + + @elseif ($item['type'] === 'event') + + * {{ $item['text'] }} + + @else + @include('native.partials.message-item', $item) + @endif + @endforeach + + {{-- The bar sits over the end of the thread, so the thread clears it, the home indicator and the typing line. --}} + + + + + @if ($loaded) + + + + @if ($canPush) + + @if ($typing) + {{ $typing }} + @endif + + @if ($replyTo) + + + + Replying to {{ $replyTo->userId === $viewer?->id ? 'yourself' : ($authors[$replyTo->userId]->name ?? 'Someone') }} + {{ $replyTo->body !== '' ? $replyTo->body : 'Image' }} + + + + + + @endif + + + + + @php($length = mb_strlen($draft)) + @if ($length >= 1600) + {{ number_format($length) }}/2,000 + @endif + + Push + + + @else + + {{ match ($access) { + 'request' => "@{$username} wants to push to you. Merge to reply, or drop it. They won't be told.", + 'waiting' => "pushed. waiting for @{$username} to merge your request", + 'restricted' => "@{$username} doesn't follow you, so you can't push to them yet", + default => "@{$username} isn't taking remotes", + } }} + @if ($access === 'request') + + + Drop + + + Merge + + + @if ($requestError) + {{ $requestError }} + @endif + @endif + + @endif + + + @endif + + {{-- A long press on a message: the reactions the web offers first, the rest, and Reply. --}} + + @if ($sheetMessage) + + + @foreach ($quickReactions as $emoji) + + + {{ $emoji }} + + + @endforeach + + {{-- EDGE's rows don't wrap on iOS, so the rest come in rows of eight. --}} + + @foreach (array_chunk($moreReactions, 8) as $row) + + @foreach ($row as $emoji) + + + {{ $emoji }} + + + @endforeach + + @endforeach + + @if ($canPush) + Reply + @endif + + @endif + + diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php new file mode 100644 index 0000000..293ae71 --- /dev/null +++ b/resources/views/native/partials/message-body.blade.php @@ -0,0 +1,23 @@ +{{-- + A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable + mentions and inline code, and fenced code as mono text in a box. $ink is the bubble's text colour, so a body + on your lane reads in that lane's label colour. +--}} +@foreach ($message->content as $block) + @if ($block->isCode()) + + + + @else +@foreach ($block->runs as $run) +@if ($run->isMention()) + +@elseif ($run->isCode) + +@else + +@endif +@endforeach + + @endif +@endforeach diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php new file mode 100644 index 0000000..4bba452 --- /dev/null +++ b/resources/views/native/partials/message-item.blade.php @@ -0,0 +1,90 @@ +{{-- + One message in a thread, as the web draws it: yours on the right in your lane, everyone else's on the left on + the wash. In an org, a run of someone else's messages starts with their avatar and name. Above the bubble, the + ghost of the message it replies to; beside it its images and link card; under it its reactions, one chip per + emoji, yours outlined in your lane and a tap to take it back or add yours. A run ends in the time, or for the + message the web gives a status, in that status. + + A long press opens the message's reactions and Reply; a double tap toggles a heart. A message still being + pushed takes neither. +--}} +@php($key = $message->id > 0 ? $message->id : $message->clientId) +@php($laneToken = \App\Devnull\Lane::token(($author ?? null)?->lane())) +@php($replyAuthor = $message->replyTo === null ? null : ($message->replyTo['user_id'] === $viewer?->id ? 'You' : ($authors[$message->replyTo['user_id']]->name ?? 'Someone'))) +@php($replyLane = $message->replyTo === null ? 'primary' : \App\Devnull\Lane::token(($authors[$message->replyTo['user_id']] ?? null)?->lane())) + + + @if ($isOrg && ! $mine && $startsRun && $author) + + @include('native.partials.avatar', ['user' => $author, 'size' => 16]) + {{ $author->name }} + + @endif + + @if ($message->replyTo !== null) + + {{ $replyAuthor }} + {{ $message->replySnippet() }} + + @endif + + @foreach ($message->images as $index => $image) + + @endforeach + + @if ($message->body !== '') + @if ($message->isPending()) + + @include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour]) + + @else + + @include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary']) + + @endif + @elseif ($message->images !== [] && ! $message->isPending()) + + + + @endif + + @if ($message->linkPreview?->isReady()) + + + {{ $message->linkPreview->site() }} + {{ $message->linkPreview->title }} + + + @endif + + @php($chips = $message->reactionChips($viewer?->id)) + @if ($chips !== []) + + @foreach ($chips as $chip) + + + {{ $chip['emoji'] }} + {{ $chip['count'] }} + + + @endforeach + + @endif + + @if ($status !== null) + + @if ($mine) + @include('native.partials.message-status', ['status' => $status['state'], 'ref' => 'glyph-'.$key, 'lane' => $viewer?->lane(), 'partnerLane' => $partner?->lane()]) + @endif + @if ($message->status === 'rejected') + rejected · + retry + @else + {{ $status['text'] }} + @endif + + @elseif ($endsRun) + {{ $message->time() }} + @endif + + diff --git a/resources/views/native/partials/message-status.blade.php b/resources/views/native/partials/message-status.blade.php new file mode 100644 index 0000000..8582ee3 --- /dev/null +++ b/resources/views/native/partials/message-status.blade.php @@ -0,0 +1,19 @@ +{{-- + The web's status glyph for a message you pushed: a dashed ring in your lane while staged, a dot once pushed, your + dot joined to the partner's once they pulled it, and a red ring when devnull didn't take it. +--}} +@php($mineToken = \App\Devnull\Lane::token($lane)) +@php($theirToken = \App\Devnull\Lane::token($partnerLane ?? null)) +@if ($status === 'pulled') + + + + + +@elseif ($status === 'staged') + +@elseif ($status === 'rejected') + +@else + +@endif diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index f6ba54b..c832b80 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -17,10 +17,18 @@ {{-- Follow waits for devnull to say whether you already do. --}} @elseif ($isMe) Edit on GitHub - @elseif ($profile->isFollowing) - Unfollow @else - Follow + {{-- The web's Remote, beside Follow, when you can push to them. --}} + + @if ($canRemote) + Remote + @endif + @if ($profile->isFollowing) + Unfollow + @else + Follow + @endif + @endif diff --git a/resources/views/native/partials/remote-row-loading.blade.php b/resources/views/native/partials/remote-row-loading.blade.php new file mode 100644 index 0000000..427e51d --- /dev/null +++ b/resources/views/native/partials/remote-row-loading.blade.php @@ -0,0 +1,13 @@ +{{-- A skeleton inbox row, the height of a real one, while devnull hasn't answered yet. --}} + + + + + + + + + + + + diff --git a/resources/views/native/partials/remote-row.blade.php b/resources/views/native/partials/remote-row.blade.php new file mode 100644 index 0000000..af27984 --- /dev/null +++ b/resources/views/native/partials/remote-row.blade.php @@ -0,0 +1,64 @@ +{{-- + 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. +--}} +@php($id = $conversation->id) +@php($last = $conversation->lastMessage) + + + @if ($conversation->isOrg()) + + @foreach (array_reverse(array_slice($others, 0, 2), true) as $index => $member) + + + @include('native.partials.avatar', ['user' => $member, 'size' => 28]) + + + @endforeach + + @elseif ($conversation->partner) + + + @include('native.partials.avatar', ['user' => $conversation->partner, 'size' => 38]) + + + @endif + + + + {{ $title }} + {{ $conversation->isOrg() ? 'org · '.count($conversation->members) : $conversation->partner?->handle() }} + + + @if ($mine && ! $conversation->isOrg() && $last && ! $last->isEvent()) + @include('native.partials.message-status', [ + 'status' => $conversation->lastPulled() ? 'pulled' : 'pushed', + 'ref' => 'status-'.$id, + 'lane' => $viewer?->lane(), + 'partnerLane' => $conversation->partner?->lane(), + ]) + @endif + @if ($last?->isEvent()) + {{ $preview }} + @else + {{ $preview }} + @endif + + + + + @if ($conversation->ahead > 0) + + {{ $conversation->ahead }} ahead + + @else + {{ \App\Devnull\Display::relativeTime($conversation->lastMessageAt) }} + @endif + + + + + + diff --git a/resources/views/native/remotes.blade.php b/resources/views/native/remotes.blade.php new file mode 100644 index 0000000..9845b25 --- /dev/null +++ b/resources/views/native/remotes.blade.php @@ -0,0 +1,61 @@ +{{-- + The web's Remotes inbox: Open, Fetched (while devnull takes requests, counting them) and Stashed along the top, in + your lane's underline, then a row per conversation, the latest first. While you have Remotes off, the tab shows + the web's page for /remotes then, its 404, word for word. +--}} + + @if ($off) + + fatal: path '/remotes' does not exist in 'HEAD' + There's nothing here. + The link may be mistyped, or whatever it pointed to has been deleted. + Back to your log + + @else + @include('native.partials.error') + @include('native.partials.refresh-failed') + + + @foreach ($tabs as $key => $label) + + + + {{ $label }} + @if ($key === 'fetched' && $requestsCount > 0) + + {{ $requestsCount }} + + @endif + + + + + @endforeach + + + + + @if (! $loaded && $error === null) + @foreach (range(1, 4) as $skeleton) + @include('native.partials.remote-row-loading', ['ref' => 'loading-'.$skeleton]) + @endforeach + @endif + + @if ($loaded && $rows === [] && $error === null) + @include('native.partials.empty', match ($tab) { + 'stashed' => ['command' => 'nothing stashed', 'message' => 'Stash a conversation from its menu. It pops back into Open when someone writes.'], + 'fetched' => ['command' => 'no requests fetched', 'message' => 'First messages from people you don’t follow wait here until you merge or drop them.'], + default => ['command' => 'no conversations yet', 'message' => 'Start one from a profile with Remote.'], + }) + @endif + + @foreach ($rows as $row) + @include('native.partials.remote-row', $row) + @endforeach + + @if ($loaded && $hasMore) + @include('native.partials.remote-row-loading', ['ref' => 'loading-more']) + @endif + + @endif + diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 2879e9d..e2cf04d 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -132,6 +132,56 @@ @endif + @if ($user) + {{-- + The web's Remotes switch, word for word, with the settings its Remotes page keeps in a menu under it + while Remotes are on: who can push to you, only while devnull offers Everyone, and Share pulls. + --}} + + + + Remotes + One-to-one messages with other developers. People you follow can push to you, and you can push to people who follow you. Whether you share pulls (read receipts) is up to you, from Remotes. + When off, nobody can message you and Remotes is hidden. Your conversations are kept for when you turn it back on. + + + + @if ($remotesError) + {{ $remotesError }} + @endif + + @if ($remotes) + @if ($remotes->everyone) + + who can push to you + @foreach (['following' => 'People you follow', 'everyone' => 'Everyone'] as $from => $label) + + + {{ $label }} + @if ($remotes->from === $from) + + @endif + + + @endforeach + {{ $remotes->requests ? 'Anyone else lands in Fetched until you merge them. Requests already fetched stay there.' : 'Anyone else can push to you once you follow them.' }} + + @endif + + + + Share pulls + When off, nobody sees when you pull their messages, and you don't see when they pull yours. + + + + @if ($remotePreferencesError) + {{ $remotePreferencesError }} + @endif + @endif + + @endif + @include('native.partials.settings-heading', [ 'title' => 'Safety', diff --git a/routes/mobile.php b/routes/mobile.php index c2bfe38..fbd4c2c 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -3,12 +3,14 @@ use App\NativeComponents\AuthCallback; use App\NativeComponents\CommitDetail; use App\NativeComponents\Connections; +use App\NativeComponents\Conversation; use App\NativeComponents\Launch; use App\NativeComponents\Layouts\TabsLayout; use App\NativeComponents\Log; use App\NativeComponents\Notifications; use App\NativeComponents\Onboarding; use App\NativeComponents\Profile; +use App\NativeComponents\Remotes; use App\NativeComponents\Safety; use App\NativeComponents\Saved; use App\NativeComponents\ThisDevice; @@ -21,17 +23,20 @@ Route::nativeGroup(TabsLayout::class, function (): void { Route::native('/log', Log::class); Route::native('/notifications', Notifications::class); - Route::native('/saved', Saved::class); + Route::native('/remotes', Remotes::class); Route::native('/profile', Profile::class); Route::native('/profile/device', ThisDevice::class); + Route::native('/profile/saved', Saved::class); - // 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). + // Commits, profiles, their followers, the people you mute and block, and + // Remotes threads 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); + Route::native("{$tab}/conversations/{username}", Conversation::class); + Route::native("{$tab}/orgs/{org}", Conversation::class); } }); diff --git a/tests/Feature/Devnull/ResponseCacheTest.php b/tests/Feature/Devnull/ResponseCacheTest.php index 369ccba..5a508b9 100644 --- a/tests/Feature/Devnull/ResponseCacheTest.php +++ b/tests/Feature/Devnull/ResponseCacheTest.php @@ -407,3 +407,20 @@ expect($this->cache->get('me'))->toBeNull(); }); + +it("keeps the Remotes inbox and a thread's first page, but never a thread's poll or an older page", function () { + $this->cache->put('remotes', [], remotesInboxJson([conversationJson()])); + $this->cache->put('remotes', ['tab' => 'stashed'], remotesInboxJson([])); + $this->cache->put('remotes/@ada/messages', [], threadJson([remoteMessageJson()])); + $this->cache->put('remotes/orgs/org-1/messages', [], threadJson([remoteMessageJson()])); + $this->cache->put('remotes/@ada/messages', ['after' => '1', 'reactions_after' => '0'], threadJson([remoteMessageJson(['id' => 2])])); + $this->cache->put('remotes', ['before' => 'conv-1'], remotesInboxJson([])); + + expect($this->cache->get('remotes'))->not->toBeNull() + ->and($this->cache->get('remotes', ['tab' => 'stashed']))->not->toBeNull() + ->and($this->cache->get('remotes/orgs/org-1/messages'))->not->toBeNull() + ->and($this->cache->get('remotes/@ada/messages')['data']['messages'][0]['id'])->toBe(1) + ->and(ResponseCache::caches('remotes/@ada/messages', ['after' => '1']))->toBeFalse() + ->and(ResponseCache::caches('remotes', ['before' => 'conv-1']))->toBeFalse() + ->and(ResponseCache::caches('remotes/reactions'))->toBeFalse(); +}); diff --git a/tests/Feature/Screens/AccountRemotesTest.php b/tests/Feature/Screens/AccountRemotesTest.php new file mode 100644 index 0000000..0c0666a --- /dev/null +++ b/tests/Feature/Screens/AccountRemotesTest.php @@ -0,0 +1,131 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * `GET /me` with Remotes as given, or off with null. + * + * @param array|null $remotes + * @return array + */ +function meWithRemotes(?array $remotes = []): array +{ + return currentUserJson(['remotes' => $remotes === null ? null : ['from' => 'following', 'pull_receipts' => true, 'requests' => false, 'everyone' => false, 'unread_count' => 0, ...$remotes]]); +} + +it("has the web's Remotes switch, word for word, with Share pulls under it", function () { + serveApi(['GET /api/v1/me' => [meWithRemotes()]]); + + $screen = Native::visit('/profile/device') + ->assertSee('Remotes') + ->assertSee('One-to-one messages with other developers. People you follow can push to you, and you can push to people who follow you. Whether you share pulls (read receipts) is up to you, from Remotes.') + ->assertSee('When off, nobody can message you and Remotes is hidden. Your conversations are kept for when you turn it back on.') + ->assertSee('Share pulls') + ->assertSee("When off, nobody sees when you pull their messages, and you don't see when they pull yours.") + ->assertDontSee('who can push to you'); + + expect(nodeByRef($screen, 'remotes-switch')['props']['value'] ?? null)->toBeTrue() + ->and(nodeByRef($screen, 'share-pulls-switch')['props']['value'] ?? null)->toBeTrue() + // After pastéis mode, before Safety. + ->and(array_column(findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['pasteis-mode', 'remotes', 'safety'], true)), 'ref'))->toBe(['pasteis-mode', 'remotes', 'safety']); +}); + +it('turns Remotes off, hiding their settings, and the Remotes tab takes it at once', function () { + serveApi([ + 'GET /api/v1/me' => [meWithRemotes()], + 'PUT /api/v1/me/remotes' => [meWithRemotes(null)], + ]); + + $screen = Native::visit('/profile/device')->toggle('remotes-switch', false)->assertDontSee('Share pulls'); + + expect(app(Account::class)->remotes())->toBeNull() + ->and(sentTo('PUT', 'me/remotes'))->toBe(['{"enabled":false}']) + ->and(nodeByRef($screen, 'remotes-switch')['props']['value'] ?? null)->toBeFalse(); +}); + +it('turns Remotes back on', function () { + app(Account::class)->rememberRemotes(null); + serveApi([ + 'GET /api/v1/me' => [meWithRemotes(null)], + 'PUT /api/v1/me/remotes' => [meWithRemotes()], + ]); + + Native::visit('/profile/device')->toggle('remotes-switch', true)->assertSee('Share pulls'); + + expect(app(Account::class)->remotes())->not->toBeNull() + ->and(sentTo('PUT', 'me/remotes'))->toBe(['{"enabled":true}']); +}); + +it("puts the switch back, in the web's words, when devnull can't save it", function () { + serveApi([ + 'GET /api/v1/me' => [meWithRemotes()], + 'PUT /api/v1/me/remotes' => [['message' => 'Server Error'], 500], + ]); + + $screen = Native::visit('/profile/device')->toggle('remotes-switch', false)->assertSee('Could not save remotes. Please try again.'); + + expect(nodeByRef($screen, 'remotes-switch')['props']['value'] ?? null)->toBeTrue() + ->and(app(Account::class)->remotes())->not->toBeNull(); +}); + +it('saves Share pulls', function () { + serveApi([ + 'GET /api/v1/me' => [meWithRemotes()], + 'PUT /api/v1/me/remote-preferences' => [meWithRemotes(['pull_receipts' => false])], + ]); + + $screen = Native::visit('/profile/device')->toggle('share-pulls-switch', false); + + expect(sentTo('PUT', 'me/remote-preferences'))->toBe(['{"from":"following","pull_receipts":false}']) + ->and(nodeByRef($screen, 'share-pulls-switch')['props']['value'] ?? null)->toBeFalse() + ->and(app(Account::class)->remotes()?->pullReceipts)->toBeFalse(); +}); + +it("puts Share pulls back, in the web's words, when devnull can't save it", function () { + serveApi([ + 'GET /api/v1/me' => [meWithRemotes()], + 'PUT /api/v1/me/remote-preferences' => [['message' => 'Server Error'], 500], + ]); + + $screen = Native::visit('/profile/device')->toggle('share-pulls-switch', false)->assertSee('Could not save your remote settings. Try again.'); + + expect(nodeByRef($screen, 'share-pulls-switch')['props']['value'] ?? null)->toBeTrue(); +}); + +it('offers who can push to you only while devnull offers everyone', function (bool $requests, string $help) { + serveApi([ + 'GET /api/v1/me' => [meWithRemotes(['everyone' => true, 'requests' => $requests])], + 'PUT /api/v1/me/remote-preferences' => [meWithRemotes(['everyone' => true, 'requests' => $requests, 'from' => 'everyone'])], + ]); + + $screen = Native::visit('/profile/device') + ->assertSee('who can push to you') + ->assertSee('People you follow') + ->assertSee('Everyone') + ->assertSee($help) + ->tap('remotes-from-everyone'); + + expect(sentTo('PUT', 'me/remote-preferences'))->toBe(['{"from":"everyone","pull_receipts":true}']) + ->and(app(Account::class)->remotes()?->from)->toBe(Remotes::Everyone); +})->with([ + 'requests on' => [true, 'Anyone else lands in Fetched until you merge them. Requests already fetched stay there.'], + 'requests off' => [false, 'Anyone else can push to you once you follow them.'], +]); + +it('remembers your Remotes whenever devnull says who you are', function () { + fakeApi([ + 'GET /api/v1/me' => [meWithRemotes(['unread_count' => 4, 'requests' => true])], + 'GET /api/v1/saved' => [pageJson([])], + ]); + + finishRefreshes(Native::visit('/profile/saved')); + + expect(app(Account::class)->remotes())->toEqual(new Remotes(requests: true, unreadCount: 4)); +}); diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php new file mode 100644 index 0000000..7400ba9 --- /dev/null +++ b/tests/Feature/Screens/ConversationTest.php @@ -0,0 +1,279 @@ + 'http://devnull.test']); + signIn(); + $this->travelTo('2026-10-19 15:30:00'); +}); + +/** + * Devnull answering a thread with Ada: its first page, then whatever a poll asks for, and every write. + * + * @param array $thread + * @param array|null $poll + */ +function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = 201): void +{ + serveApi(function (Request $request) use ($thread, $poll, $pushStatus) { + $path = parse_url($request->url(), PHP_URL_PATH); + $query = (string) parse_url($request->url(), PHP_URL_QUERY); + + return match (true) { + $request->method() === 'GET' && $path === '/api/v1/remotes/@ada/messages' => Http::response(str_contains($query, 'after=') ? ($poll ?? threadJson([])) : $thread), + $request->method() === 'POST' && $path === '/api/v1/remotes/@ada/messages' => $pushStatus >= 300 + ? Http::response(['message' => 'Nope.'], $pushStatus) + : Http::response(['data' => remoteMessageJson([ + 'id' => 99, + 'user_id' => RemotesViewerId, + 'client_id' => json_decode($request->body(), true)['client_id'], + 'body' => json_decode($request->body(), true)['body'], + ])], $pushStatus), + $path === '/api/v1/me' => Http::response(currentUserJson()), + str_ends_with($path, '/reactions') && $request->method() === 'POST' => Http::response(['reactions' => [['user_id' => RemotesViewerId, 'emoji' => json_decode($request->body(), true)['emoji']]]]), + str_ends_with($path, '/reactions') && $request->method() === 'DELETE' => Http::response(['reactions' => []]), + default => Http::response(['usage' => []], $request->method() === 'GET' ? 200 : 204), + }; + }); +} + +it('shows the thread with your messages on the right and theirs on the left, titled by who it is with', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?', 'created_at' => '2026-10-18T09:00:00Z']), + remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'merged it', 'created_at' => '2026-10-19T14:00:00Z']), + ], ['read_through_id' => 2, 'partner_read_through_id' => 2, 'partner_read_at' => '2026-10-19T14:05:00Z'])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) + ->assertNavTitle('Ada Lovelace') + ->assertSee('did you see the PR?') + ->assertSee('merged it') + ->assertSee('Yesterday') + ->assertSee('Today') + ->assertSee('pulled 14:05'); + + // Theirs at the start of the row, yours at the end, in a thread that opens on its latest message. + expect(nodeByRef($screen, 'message-1')['layout']['justify_content'])->toBe(0) + ->and(nodeByRef($screen, 'message-2')['layout']['justify_content'])->toBe(2) + ->and(nodeByRef($screen, 'thread')['props']['scroll_anchor'] ?? null)->toBe('bottom'); +}); + +it('marks HEAD where you left off, and pulls what is showing', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'first']), + remoteMessageJson(['id' => 2, 'body' => 'second']), + remoteMessageJson(['id' => 3, 'body' => 'third']), + ], ['read_through_id' => 1])); + + finishRefreshes(Native::visit('/remotes/conversations/ada'))->assertSee('HEAD · 2 new'); + + expect(sentTo('PUT', 'remotes/@ada/read'))->toBe(['{"through":3}']); +}); + +it('polls for what came after the last message, and for reactions changed since', function () { + serveRemoteThread( + threadJson([remoteMessageJson(['id' => 1, 'body' => 'first'])], ['read_through_id' => 1, 'reactions_version' => 4]), + threadJson([remoteMessageJson(['id' => 2, 'body' => 'second'])], [ + 'reactions_version' => 5, + 'reaction_changes' => [['id' => 1, 'reactions' => [['user_id' => 'u2', 'emoji' => '🚀']]]], + 'partner_typing' => true, + ]), + ); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + finishRefreshes($screen->call('poll')) + ->assertSee('second') + ->assertSee('🚀') + ->assertSee('@ada is typing…'); + + $polls = collect(Http::recorded())->map(fn (array $sent): string => (string) parse_url($sent[0]->url(), PHP_URL_QUERY))->filter(fn (string $query): bool => str_contains($query, 'after')); + + expect($polls->first())->toBe('after=1&reactions_after=4') + ->and(sentTo('PUT', 'remotes/@ada/read'))->toBe(['{"through":2}']); +}); + +it('waits longer between polls once nobody has written or typed for a minute', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + $polls = fn (): int => collect(Http::recorded())->filter(fn (array $sent): bool => str_contains($sent[0]->url(), 'after='))->count(); + + finishRefreshes($screen->call('poll')); + finishRefreshes($screen->call('poll')); + + expect($polls())->toBe(1); +}); + +it('pushes a message staged at once, and pushed once devnull has it', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?'])], ['read_through_id' => 1])); + holdRefreshes(); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) + ->set('draft', 'on it') + ->tap('push') + ->assertSee('on it') + ->assertSee('staged') + ->assertSet('draft', ''); + + finishRefreshes($screen)->assertSee('pushed')->assertSet('pending', []); + + $sent = json_decode(sentTo('POST', 'remotes/@ada/messages')[0], true); + + expect($sent['body'])->toBe('on it') + ->and($sent['client_id'])->toMatch('/^[0-9a-f-]{36}$/'); +}); + +it('says a message devnull refused was rejected, and retries it under the same client id', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1]), pushStatus: 403); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->set('draft', 'hello')->call('send'); + finishRefreshes($screen)->assertSee('rejected ·')->assertSee('retry'); + + $clientId = $screen->instance()->pending[0]->clientId; + + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); + finishRefreshes($screen->call('retry', $clientId))->assertSet('pending', []); + + expect(array_map(fn (string $body): string => json_decode($body, true)['client_id'], sentTo('POST', 'remotes/@ada/messages')))->toBe([$clientId, $clientId]); +}); + +it('replies to a message from its sheet', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?'])], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) + ->call('openMessage', 1) + ->assertSet('sheetOpen', true) + ->tap('reply') + ->assertSee('Replying to Ada Lovelace') + ->set('draft', 'yes') + ->call('send'); + + finishRefreshes($screen); + + expect(json_decode(sentTo('POST', 'remotes/@ada/messages')[0], true)['reply_to_id'])->toBe(1); +}); + +it('tells the others you are typing at most every three seconds', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + finishRefreshes($screen->set('draft', 'o')); + finishRefreshes($screen->set('draft', 'on')); + + expect(sentTo('PUT', 'remotes/@ada/typing'))->toHaveCount(1); + + $this->travel(4)->seconds(); + finishRefreshes($screen->set('draft', 'on it')); + + expect(sentTo('PUT', 'remotes/@ada/typing'))->toHaveCount(2); +}); + +it('reacts at once, and takes it back if devnull says no', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->call('heart', 1)->assertSee('❤️'); + + expect(sentTo('POST', 'remotes/messages/1/reactions'))->toBe(['{"emoji":"❤️"}']); + + serveApi(fn () => Http::response(['message' => 'Nope.'], 403)); + finishRefreshes($screen->call('toggleReaction', 1, '🚀')); + + expect($screen->instance()->messages[0]->reactions)->toBe([['user_id' => RemotesViewerId, 'emoji' => '❤️']]); +}); + +it("says why a message can't go, in the web's words, and merges or drops a request", function (string $access, string $words) { + app(Account::class)->rememberRemotes(new Remotes(requests: true)); + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1, 'access' => $access])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->assertSee($words); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'message-input'))->toBeNull(); + + if ($access === 'request') { + finishRefreshes($screen->tap('merge')); + + expect(sentTo('POST', 'remotes/@ada/merge'))->toHaveCount(1) + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'message-input'))->not->toBeNull(); + } +})->with([ + 'request' => ['request', "@ada wants to push to you. Merge to reply, or drop it. They won't be told."], + 'waiting' => ['waiting', 'pushed. waiting for @ada to merge your request'], + 'restricted' => ['restricted', "@ada doesn't follow you, so you can't push to them yet"], + 'closed' => ['closed', "@ada isn't taking remotes"], +]); + +it('drops a request and goes back to the inbox', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1, 'access' => 'request'])); + + finishRefreshes(finishRefreshes(Native::visit('/remotes/conversations/ada'))->tap('drop'))->assertWentBack(); + + expect(sentTo('POST', 'remotes/@ada/drop'))->toHaveCount(1); +}); + +it('says hi to an empty thread', function () { + serveRemoteThread(threadJson([])); + + finishRefreshes(Native::visit('/remotes/conversations/ada'))->assertSee('empty history. say hi to @ada'); +}); + +it('loads the messages before the first one showing', function () { + serveApi(function (Request $request) { + $query = (string) parse_url($request->url(), PHP_URL_QUERY); + + return Http::response(match (true) { + str_contains($query, 'before=5') => threadJson([remoteMessageJson(['id' => 4, 'body' => 'older'])]), + str_contains($request->url(), '/messages') => threadJson([remoteMessageJson(['id' => 5, 'body' => 'newer'])], ['has_older' => true, 'read_through_id' => 5]), + default => currentUserJson(), + }); + }); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->tap('load-older')->assertSee('older'); + + expect(array_map(fn ($message): int => $message->id, $screen->instance()->messages))->toBe([4, 5]) + ->and($screen->get('hasOlder'))->toBeFalse(); +}); + +it('shows an org with who wrote what, its events, and who is typing', function () { + serveApi(fn (Request $request) => Http::response(str_contains($request->url(), 'orgs/org-1/messages') ? ['data' => [ + 'org' => ['id' => 'org-1', 'name' => 'dev/null core'], + 'messages' => [ + remoteMessageJson(['id' => 1, 'user_id' => RemotesViewerId, 'body' => 'dev/null core', 'event' => 'created', 'user' => userJson()]), + remoteMessageJson(['id' => 2, 'body' => 'hello org', 'user' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace'])]), + ], + 'has_older' => false, + 'reactions_version' => 0, + 'reaction_changes' => [], + 'read_through_id' => 2, + 'members' => [ + ['user' => userJson(), 'role' => 'maintainer', 'read_through_id' => 2], + ['user' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace']), 'role' => 'contributor', 'read_through_id' => 2], + ], + 'typing' => ['ada'], + 'is_archived' => false, + 'role' => 'maintainer', + 'access' => 'open', + ]] : currentUserJson())); + + finishRefreshes(Native::visit('/remotes/orgs/org-1')) + ->assertNavTitle('dev/null core') + ->assertSee('* you created dev/null core') + ->assertSee('Ada Lovelace') + ->assertSee('hello org') + ->assertSee('@ada is typing…'); +}); + +it('stashes the conversation from its menu and goes back', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); + + finishRefreshes(finishRefreshes(Native::visit('/remotes/conversations/ada'))->call('stash'))->assertWentBack(); + + expect(sentTo('POST', 'remotes/@ada/archive'))->toHaveCount(1); +}); diff --git a/tests/Feature/Screens/DraftStashTest.php b/tests/Feature/Screens/DraftStashTest.php index 53941ce..b8b79c7 100644 --- a/tests/Feature/Screens/DraftStashTest.php +++ b/tests/Feature/Screens/DraftStashTest.php @@ -252,7 +252,7 @@ function stashLabel(TestableComponent $screen, string $ref): array stash('quote-c1', 'This, all of it.'); stash('reply-c1', 'A reply in progress'); - $screen = finishRefreshes(Native::visit('/saved', platform: 'ios')); + $screen = finishRefreshes(Native::visit('/profile/saved', platform: 'ios')); expect(showsRef($screen, 'quote-stash-c1'))->toBeTrue() ->and(showsRef($screen, 'reply-stash-c1'))->toBeTrue(); diff --git a/tests/Feature/Screens/ProfileRemoteTest.php b/tests/Feature/Screens/ProfileRemoteTest.php new file mode 100644 index 0000000..9a8f43b --- /dev/null +++ b/tests/Feature/Screens/ProfileRemoteTest.php @@ -0,0 +1,79 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Ada's profile, following you or not. + * + * @return array + */ +function adaRemoteProfile(bool $followsYou): array +{ + return [ + 'data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace']), + 'is_following' => false, + 'follows_you' => $followsYou, + 'is_muting' => false, + 'activity' => ['scale' => 'week', 'buckets' => []], + 'streaks' => ['longest' => 0, 'current' => 0], + 'pinned_commit' => null, + ]; +} + +it("offers the web's Remote beside Follow to someone who follows you, opening your thread with them", function () { + fakeApi([ + 'GET /api/v1/users/ada' => [adaRemoteProfile(followsYou: true)], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + ]); + + $screen = finishRefreshes(Native::visit('/log/users/ada')); + + expect(nodeByRef($screen, 'remote')['props']['a11y_label'] ?? null)->toBe('Remote @ada'); + + $screen->tap('remote')->assertNavigatedTo('/log/conversations/ada'); +}); + +it('hides Remote where the web would not offer it', function (bool $followsYou, ?Remotes $remotes) { + app(Account::class)->rememberRemotes($remotes); + fakeApi([ + 'GET /api/v1/users/ada' => [adaRemoteProfile($followsYou)], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + ]); + + $screen = finishRefreshes(Native::visit('/log/users/ada')); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'remote'))->toBeNull(); +})->with([ + "they don't follow you, while requests are off" => [false, new Remotes], + 'you have Remotes off' => [true, null], +]); + +it('offers Remote to anyone while devnull takes requests', function () { + app(Account::class)->rememberRemotes(new Remotes(requests: true)); + fakeApi([ + 'GET /api/v1/users/ada' => [adaRemoteProfile(followsYou: false)], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + ]); + + expect(nodeByRef(finishRefreshes(Native::visit('/log/users/ada')), 'remote'))->not->toBeEmpty(); +}); + +it('opens Saved from your own profile, and the account beside it', function () { + fakeApi([ + 'GET /api/v1/users/local_dev' => [['data' => userWithCountsJson(), 'is_following' => false, 'activity' => ['scale' => 'week', 'buckets' => []], 'streaks' => ['longest' => 0, 'current' => 0], 'pinned_commit' => null]], + 'GET /api/v1/users/local_dev/commits' => [pageJson([])], + ]); + + $screen = finishRefreshes(Native::visit('/profile'))->assertTabActive('Profile'); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'remote'))->toBeNull(); + + $screen->press('openSaved')->assertNavigatedTo('/profile/saved'); +}); diff --git a/tests/Feature/Screens/RemotesTest.php b/tests/Feature/Screens/RemotesTest.php new file mode 100644 index 0000000..d11ac48 --- /dev/null +++ b/tests/Feature/Screens/RemotesTest.php @@ -0,0 +1,181 @@ + 'http://devnull.test']); + signIn(); + $this->travelTo('2026-10-19 15:30:00'); +}); + +it('lists your conversations, the latest first, as the web inbox does', function () { + fakeApi(['GET /api/v1/remotes' => [remotesInboxJson([ + conversationJson(['ahead' => 2, 'last_message' => remoteMessageJson(['id' => 7, 'body' => 'did you see the PR?'])]), + conversationJson([ + 'id' => 'conv-2', + 'partner' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace Hopper']), + 'last_message' => remoteMessageJson(['id' => 5, 'user_id' => '0199e5c4-0000-7000-8000-000000000001', 'body' => "thanks!\nsee you"]), + 'last_message_at' => '2026-10-19T13:30:00.000000Z', + 'partner_read_through_id' => 5, + ]), + ], unread: 2)]]); + + $screen = finishRefreshes(Native::visit('/remotes')) + ->assertNavTitle('Remotes') + ->assertTabActive('Remotes') + ->assertSee('Ada Lovelace') + ->assertSee('@ada') + ->assertSee('did you see the PR?') + ->assertSee('2 ahead') + ->assertSee('Grace Hopper') + ->assertSee('you: thanks! see you') + ->assertSee('2h'); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-conv-1', 'remote-conv-2']) + ->and(nodeByRef($screen, 'status-conv-2')['props']['a11y_label'] ?? null)->toBe('pulled') + ->and(app(Account::class)->unreadRemotes())->toBe(2); +}); + +it('lists an org by its name, with how many are in it', function () { + fakeApi(['GET /api/v1/remotes' => [remotesInboxJson([conversationJson([ + 'id' => 'org-1', + 'kind' => 'org', + 'name' => null, + 'partner' => null, + 'members' => [ + ['user' => userJson(), 'role' => 'maintainer'], + ['user' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace']), 'role' => 'contributor'], + ['user' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace Hopper']), 'role' => 'contributor'], + ], + 'last_message' => remoteMessageJson(['user' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace'])]), + ])])]]); + + finishRefreshes(Native::visit('/remotes')) + ->assertSee('Ada, Grace') + ->assertSee('org · 3') + ->assertSee('Ada: Shipped it.') + ->tap('remote-org-1') + ->assertNavigatedTo('/remotes/orgs/org-1'); +}); + +it('opens a conversation in the Remotes tab', function () { + fakeApi(['GET /api/v1/remotes' => [remotesInboxJson([conversationJson()])]]); + + finishRefreshes(Native::visit('/remotes'))->tap('remote-conv-1')->assertNavigatedTo('/remotes/conversations/ada'); +}); + +it('opens on the inbox kept last time, with no skeleton, then refreshes behind it', function () { + holdRefreshes(); + keep('remotes', remotesInboxJson([conversationJson()])); + serveApi(['GET /api/v1/remotes' => [remotesInboxJson([conversationJson(['id' => 'conv-2', 'partner' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace Hopper'])]), conversationJson()])]]); + + $screen = Native::visit('/remotes')->assertSee('Ada Lovelace')->assertDontSee('Grace Hopper'); + + expect(showsLoading($screen->tree()))->toBeFalse(); + + finishRefreshes($screen)->assertSee('Grace Hopper'); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-conv-2', 'remote-conv-1']); +}); + +it('shows skeleton rows until devnull first answers', function () { + holdRefreshes(); + serveApi(['GET /api/v1/remotes' => [remotesInboxJson([])]]); + + $screen = Native::visit('/remotes'); + + expect(showsLoading($screen->tree()))->toBeTrue(); + + finishRefreshes($screen)->assertSee('no conversations yet'); +}); + +it('polls the inbox every 15 seconds, as the web does', function () { + serveApi(['GET /api/v1/remotes' => [remotesInboxJson([conversationJson()])]]); + + $screen = finishRefreshes(Native::visit('/remotes')); + + serveApi(['GET /api/v1/remotes' => [remotesInboxJson([conversationJson(['ahead' => 1, 'last_message' => remoteMessageJson(['id' => 2, 'body' => 'still there?'])])], unread: 1)]]); + + finishRefreshes($screen->call('pollInbox'))->assertSee('still there?')->assertSee('1 ahead'); + + expect(app(Account::class)->unreadRemotes())->toBe(1); +}); + +it('has the web tabs, with Fetched only while devnull takes requests, counting them', function (bool $requests) { + app(Account::class)->rememberRemotes(new Remotes(requests: $requests)); + fakeApi(['GET /api/v1/remotes' => [remotesInboxJson([], requests: 2)]]); + + $screen = finishRefreshes(Native::visit('/remotes')); + + expect(childRefs($screen, 'remotes-tabs'))->toBe($requests ? ['tab-open', 'tab-fetched', 'tab-stashed'] : ['tab-open', 'tab-stashed']); + + if ($requests) { + expect(nodeByRef($screen, 'requests-count'))->not->toBeEmpty(); + } +})->with(['requests on' => true, 'requests off' => false]); + +it('lists the stashed conversations and the fetched requests on their tabs, with their own empty states', function () { + app(Account::class)->rememberRemotes(new Remotes(requests: true)); + serveApi(function (Request $request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return Http::response(remotesInboxJson(($query['tab'] ?? 'open') === 'stashed' ? [conversationJson(['is_archived' => true])] : [])); + }); + + $screen = finishRefreshes(Native::visit('/remotes'))->assertSee('no conversations yet'); + + finishRefreshes($screen->tap('tab-stashed'))->assertSee('Ada Lovelace')->assertSet('tab', 'stashed'); + finishRefreshes($screen->tap('tab-fetched'))->assertSee('no requests fetched'); + + expect(collect(Http::recorded())->map(fn (array $sent): string => (string) parse_url($sent[0]->url(), PHP_URL_QUERY))->filter()->values()->all()) + ->toContain('tab=stashed', 'tab=fetched'); +}); + +it('loads older conversations before the last one showing', function () { + serveApi(function (Request $request) { + $before = str_contains($request->url(), 'before=conv-1'); + + return Http::response(remotesInboxJson($before + ? [conversationJson(['id' => 'conv-0', 'partner' => userJson(['id' => 'u4', 'username' => 'linus', 'name' => 'Linus'])])] + : [conversationJson()], hasMore: ! $before)); + }); + + $screen = finishRefreshes(Native::visit('/remotes'))->call('loadMore')->assertSee('Linus'); + + expect(childRefs($screen, 'remotes'))->toBe(['remote-conv-1', 'remote-conv-0']) + ->and($screen->get('hasMore'))->toBeFalse(); +}); + +it("shows the web's page for /remotes while you have Remotes off", function () { + app(Account::class)->rememberRemotes(null); + fakeApi(['GET /api/v1/remotes' => [['message' => 'Not Found.'], 404]]); + + finishRefreshes(Native::visit('/remotes')) + ->assertSee("fatal: path '/remotes' does not exist in 'HEAD'") + ->assertSee("There's nothing here.") + ->assertSee('The link may be mistyped, or whatever it pointed to has been deleted.') + ->assertSee('Back to your log') + ->tap('back-to-log') + ->assertReplacedWith('/log'); +}); + +it('takes a 404 from devnull as Remotes turned off on the web', function () { + fakeApi(['GET /api/v1/remotes' => [['message' => 'Not Found.'], 404]]); + + finishRefreshes(Native::visit('/remotes'))->assertSee("There's nothing here."); + + expect(app(Account::class)->remotes())->toBeNull(); +}); + +it('wears the unread count on the Remotes tab', function () { + app(Account::class)->rememberRemotes(new Remotes(unreadCount: 3)); + fakeApi(['GET /api/v1/log/following' => [pageJson([])]]); + + $tab = findNode(Native::visit('/log')->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'bottom_nav_item' && ($node['props']['label'] ?? null) === 'Remotes'); + + expect($tab['props']['badge'] ?? null)->toBe('3'); +}); diff --git a/tests/Feature/Screens/SavedTest.php b/tests/Feature/Screens/SavedTest.php index 6aedb95..83b3db2 100644 --- a/tests/Feature/Screens/SavedTest.php +++ b/tests/Feature/Screens/SavedTest.php @@ -47,9 +47,9 @@ function savedIds(TestableComponent $screen): array it('lists the commits you saved, last saved first, in the rows of the log', function () { fakeApi(['GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])]]); - $screen = finishRefreshes(Native::visit('/saved')) + $screen = finishRefreshes(Native::visit('/profile/saved')) ->assertNavTitle('Saved') - ->assertTabActive('Saved') + ->assertTabActive('Profile') ->assertSee('Only you') ->assertSee('Commits you want to come back to. Most recently saved first.') ->assertSee('Saved s2') @@ -64,7 +64,7 @@ function savedIds(TestableComponent $screen): array keep('saved', savedPage([savedCommit('s1')])); serveApi(['GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])]]); - $screen = Native::visit('/saved')->assertSee('Saved s1')->assertDontSee('Saved s2'); + $screen = Native::visit('/profile/saved')->assertSee('Saved s1')->assertDontSee('Saved s2'); expect(showsLoading($screen->tree()))->toBeFalse() ->and(Http::recorded())->toHaveCount(0); @@ -79,7 +79,7 @@ function savedIds(TestableComponent $screen): array holdRefreshes(); serveApi(['GET /api/v1/saved' => [savedPage([savedCommit('s1')])]]); - $screen = Native::visit('/saved'); + $screen = Native::visit('/profile/saved'); expect(showsLoading($screen->tree()))->toBeTrue(); @@ -91,7 +91,7 @@ function savedIds(TestableComponent $screen): array it('says so as the web does when nothing is saved, and Explore Trending opens the Log on Trending', function () { fakeApi(['GET /api/v1/saved' => [savedPage([])]]); - $screen = finishRefreshes(Native::visit('/saved')) + $screen = finishRefreshes(Native::visit('/profile/saved')) ->assertSee('fatal: no saved commits yet') ->assertSee('Save a commit to find it here. Your saved commits are private.') ->assertDontSee("You've reached the end of your saved commits."); @@ -108,7 +108,7 @@ function savedIds(TestableComponent $screen): array 'DELETE /api/v1/commits/s2/bookmark' => [$status === 200 ? ['data' => savedCommit('s2', ['is_saved' => false])] : ['message' => 'Server Error'], $status], ]); - $screen = finishRefreshes(Native::visit('/saved'))->tap('save-s2'); + $screen = finishRefreshes(Native::visit('/profile/saved'))->tap('save-s2'); expect(savedIds($screen))->toBe($ids) ->and(array_column(kept('saved')['data'], 'id'))->toBe($ids) @@ -125,7 +125,7 @@ function savedIds(TestableComponent $screen): array 'DELETE /api/v1/commits/r1/bookmark' => [['data' => [...$reply, 'is_saved' => false]]], ]); - $screen = finishRefreshes(Native::visit('/saved'))->call('toggleBookmark', 'r1'); + $screen = finishRefreshes(Native::visit('/profile/saved'))->call('toggleBookmark', 'r1'); expect(savedIds($screen))->toBe(['s1']); }); @@ -138,7 +138,7 @@ function savedIds(TestableComponent $screen): array 'GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])], ]); - $screen = Native::visit('/saved')->tap('save-s3')->assertDontSee('fatal: no saved commits yet'); + $screen = Native::visit('/profile/saved')->tap('save-s3')->assertDontSee('fatal: no saved commits yet'); expect(showsLoading($screen->tree()))->toBeTrue(); @@ -152,7 +152,7 @@ function savedIds(TestableComponent $screen): array ? savedPage([savedCommit('s1')]) : savedPage([savedCommit('s3'), savedCommit('s2')], nextCursor: 'page2'))); - $screen = finishRefreshes(Native::visit('/saved'))->assertElement('row', fn (array $node): bool => ($node['ref'] ?? null) === 'loading-more'); + $screen = finishRefreshes(Native::visit('/profile/saved'))->assertElement('row', fn (array $node): bool => ($node['ref'] ?? null) === 'loading-more'); $screen->call('loadMore')->assertSee("You've reached the end of your saved commits."); @@ -171,7 +171,7 @@ function savedIds(TestableComponent $screen): array keep('saved', savedPage([savedCommit('s5'), savedCommit('s4'), savedCommit('s3')], nextCursor: 'page2')); serveApi(['GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')], nextCursor: 'page3')]]); - $screen = Native::visit('/saved')->call('loadMore'); + $screen = Native::visit('/profile/saved')->call('loadMore'); expect(savedIds($screen))->toBe(['s5', 's4', 's3', 's2', 's1']); serveApi(['GET /api/v1/saved' => [savedPage(array_map(fn (string $id): array => savedCommit($id), $fresh), $cursor)]]); @@ -201,13 +201,13 @@ function savedIds(TestableComponent $screen): array finishRefreshes(Native::visit('/log'))->tap('save-c1'); holdRefreshes(); - $saved = Native::visit('/saved')->assertSee('Worth keeping'); + $saved = Native::visit('/profile/saved')->assertSee('Worth keeping'); expect(savedIds($saved))->toBe(['c1', 's1']); Native::visit('/log')->tap('save-s1'); holdRefreshes(); - expect(savedIds(Native::visit('/saved')))->toBe(['c1']); + expect(savedIds(Native::visit('/profile/saved')))->toBe(['c1']); }); it('drops a commit unsaved on its own page as soon as you come back, even past the first page', function () { @@ -220,10 +220,10 @@ function savedIds(TestableComponent $screen): array 'DELETE /api/v1/commits/s1/bookmark' => [['data' => savedCommit('s1', ['is_saved' => false])]], ]); - $saved = Native::visit('/saved')->call('loadMore'); + $saved = Native::visit('/profile/saved')->call('loadMore'); expect(savedIds($saved))->toBe(['s2', 's1']); - $commit = $saved->tap('commit-s1')->assertNavigatedTo('/saved/commits/s1')->follow()->tap('save-s1'); + $commit = $saved->tap('commit-s1')->assertNavigatedTo('/profile/commits/s1')->follow()->tap('save-s1'); expect(savedIds($commit->goBack()))->toBe(['s2']); }); @@ -237,11 +237,11 @@ function savedIds(TestableComponent $screen): array 'GET /api/v1/commits/s1/replies' => [pageJson([])], ]); - $commit = Native::visit('/saved/commits/s1'); + $commit = Native::visit('/profile/commits/s1'); $commit->tap('save-s1')->tap('save-s1'); holdRefreshes(); - expect(savedIds(Native::visit('/saved')))->toBe(['s1']); + expect(savedIds(Native::visit('/profile/saved')))->toBe(['s1']); }); it('deletes your own saved commit from its menu, and lets its row go', function () { @@ -260,7 +260,7 @@ function savedIds(TestableComponent $screen): array ])); }); - $screen = finishRefreshes(Native::visit('/saved', platform: 'ios')) + $screen = finishRefreshes(Native::visit('/profile/saved', platform: 'ios')) ->tap('delete-s2') ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-commit']) ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'rm s2 → /dev/null') @@ -275,7 +275,7 @@ function savedIds(TestableComponent $screen): array signIn(['lane' => 4]); fakeApi(['GET /api/v1/saved' => [savedPage([savedCommit('s1')])]]); - $screen = finishRefreshes(Native::visit('/saved', platform: 'ios'))->tap("{$item}-s1"); + $screen = finishRefreshes(Native::visit('/profile/saved', platform: 'ios'))->tap("{$item}-s1"); expect(nodeByRef($screen, $button)['props']['variant'] ?? null)->toBe('lane-4'); })->with([ @@ -283,9 +283,9 @@ function savedIds(TestableComponent $screen): array 'Report commit' => ['report', 'send-report'], ]); -it('opens commits and profiles on the Saved tab', function () { +it('opens commits and profiles under your profile, where Saved opens', function () { fakeApi(['GET /api/v1/saved' => [savedPage([savedCommit('s1')])]]); - finishRefreshes(Native::visit('/saved'))->tap('commit-s1')->assertNavigatedTo('/saved/commits/s1'); - finishRefreshes(Native::visit('/saved'))->call('openProfile', 'ada')->assertNavigatedTo('/saved/users/ada'); + finishRefreshes(Native::visit('/profile/saved'))->tap('commit-s1')->assertNavigatedTo('/profile/commits/s1'); + finishRefreshes(Native::visit('/profile/saved'))->call('openProfile', 'ada')->assertNavigatedTo('/profile/users/ada'); }); diff --git a/tests/Feature/Screens/TabsTest.php b/tests/Feature/Screens/TabsTest.php index 3d29102..1047ed1 100644 --- a/tests/Feature/Screens/TabsTest.php +++ b/tests/Feature/Screens/TabsTest.php @@ -21,14 +21,14 @@ function tabIcons(TestableComponent $screen): array ->all(); } -it('has Log, Notifications, Saved and Profile tabs with the Lucide icons of the web nav', function (string $platform) { +it('has Log, Notifications, Remotes and Profile tabs with the Lucide icons of the web nav', function (string $platform) { Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); // The same drawings on both platforms. A custom icon has no Material font variant. expect(tabIcons(Native::visit('/log', platform: $platform)))->toBe([ 'Log' => ['custom:git-commit-vertical', null], 'Notifications' => ['custom:bell', null], - 'Saved' => ['custom:bookmark', null], + 'Remotes' => ['custom:messages-square', null], 'Profile' => ['custom:user-round', null], ]); })->with(['ios', 'android']); @@ -63,8 +63,9 @@ function tabIcons(TestableComponent $screen): array Native::visit('/profile/commits/c1')->call('openProfile', 'ada')->assertNavigatedTo('/profile/users/ada'); Native::visit('/notifications/commits/c1')->call('openCommit', 'c0')->assertNavigatedTo('/notifications/commits/c0'); - Native::visit('/saved/commits/c1')->call('openProfile', 'ada')->assertNavigatedTo('/saved/users/ada'); - Native::visit('/saved/users/ada')->call('openCommit', 'c0')->assertNavigatedTo('/saved/commits/c0'); + Native::visit('/remotes/commits/c1')->call('openProfile', 'ada')->assertNavigatedTo('/remotes/users/ada'); + Native::visit('/remotes/users/ada')->call('openCommit', 'c0')->assertNavigatedTo('/remotes/commits/c0'); + Native::visit('/profile/saved')->call('openCommit', 'c0')->assertNavigatedTo('/profile/commits/c0'); }); it('stays put when a tap leads to the commit already showing', function () { diff --git a/tests/Pest.php b/tests/Pest.php index c18b1d2..4680c06 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -797,3 +797,107 @@ function dayMarkers(TestableComponent $screen): array return $text['text'] ?? $text['props']['text'] ?? ''; }, findNodes($tree, fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'day-'))); } + +/** + * A Remotes message as devnull sends it to someone in the conversation. Its segments follow its body unless a test + * sends its own. + * + * @param array $overrides + * @return array + */ +function remoteMessageJson(array $overrides = []): array +{ + $body = $overrides['body'] ?? 'Shipped it.'; + + return [ + 'id' => 1, + 'user_id' => 'u2', + 'client_id' => '9f0f6f3e-0000-4000-8000-000000000001', + 'body' => $body, + 'body_segments' => $body === '' ? [] : [['type' => 'text', 'text' => $body]], + 'mentions' => [], + 'reply_to_id' => null, + 'reply_to' => null, + 'link_preview' => null, + 'images' => [], + 'reactions' => [], + 'event' => null, + 'subject_id' => null, + 'created_at' => '2026-10-19T15:00:00Z', + ...$overrides, + ]; +} + +/** + * A row of the Remotes inbox: a conversation with Ada, unless a test says otherwise. + * + * @param array $overrides + * @return array + */ +function conversationJson(array $overrides = []): array +{ + return [ + 'id' => 'conv-1', + 'kind' => 'direct', + 'name' => null, + 'partner' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace', 'lane' => 1]), + 'members' => null, + 'last_message' => remoteMessageJson(), + 'last_message_at' => '2026-10-19T15:00:00.000000Z', + 'ahead' => 0, + 'partner_read_through_id' => 0, + 'partner_read_at' => null, + 'is_archived' => false, + ...$overrides, + ]; +} + +/** + * `GET /remotes` as devnull answers it. + * + * @param list> $conversations + * @return array + */ +function remotesInboxJson(array $conversations, bool $hasMore = false, int $unread = 0, int $requests = 0): array +{ + return ['data' => $conversations, 'has_more' => $hasMore, 'unread_count' => $unread, 'requests_count' => $requests]; +} + +/** + * A Remotes thread with Ada as devnull answers it, its first page or a poll. + * + * @param list> $messages + * @param array $overrides + * @return array + */ +function threadJson(array $messages, array $overrides = []): array +{ + return ['data' => [ + 'partner' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace', 'lane' => 1]), + 'messages' => $messages, + 'has_older' => false, + 'reactions_version' => 0, + 'reaction_changes' => [], + 'read_through_id' => 0, + 'partner_read_through_id' => 0, + 'partner_read_at' => null, + 'partner_typing' => false, + 'is_archived' => false, + 'access' => 'open', + ...$overrides, + ]]; +} + +/** + * The requests sent to devnull with a method and a path, as their bodies. + * + * @return list + */ +function sentTo(string $method, string $path): array +{ + return collect(Http::recorded()) + ->filter(fn (array $sent): bool => $sent[0]->method() === $method && parse_url($sent[0]->url(), PHP_URL_PATH) === '/api/v1/'.$path) + ->map(fn (array $sent): string => $sent[0]->body()) + ->values() + ->all(); +} From 4e94ad3248d12654d5f9bdcb2eae86e6622f19d7 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 20:30:13 +0100 Subject: [PATCH 2/5] Keep a reader's place in a Remotes thread, and fix what the simulator turned up Each message is now a row of the thread's scroll view, not of one column inside it, so SwiftUI keeps a reader's place by the rows on screen. With NativePHP/mobile-ui#141 (combined/dev-null-app 6e92378), a message arriving no longer pulls someone reading back down to it, and still follows someone at the bottom. Bubbles get a set width, since a column that only hugged its bubble measured the text on one line and clipped it. HEAD only shows when something above it was read, as on the web. The thread's own path() clashed with OpensInTab's and silently stopped View profile and mention taps. And a devnull too old to send remotes on /me no longer reads as Remotes turned off. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/CurrentUser.php | 3 +- app/NativeComponents/Conversation.php | 28 +++---- composer.lock | 8 +- resources/views/native/conversation.blade.php | 83 ++++++++++--------- .../native/partials/message-item.blade.php | 3 +- tests/Feature/Screens/AccountRemotesTest.php | 11 +++ tests/Feature/Screens/ConversationTest.php | 15 ++++ 7 files changed, 91 insertions(+), 60 deletions(-) diff --git a/app/Devnull/Data/CurrentUser.php b/app/Devnull/Data/CurrentUser.php index 4641f9b..c082edb 100644 --- a/app/Devnull/Data/CurrentUser.php +++ b/app/Devnull/Data/CurrentUser.php @@ -57,7 +57,8 @@ public static function fromArray(array $json): self pasteisMode: ($data['pasteis_mode'] ?? false) === true, pasteisToday: Pasteis::fromKept(['remaining' => $data['pasteis_remaining'] ?? null, 'resets_at' => $data['pasteis_resets_at'] ?? null]), pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0, - remotes: Remotes::fromArray($data['remotes'] ?? null), + // Only devnull saying null means they're off: a devnull too old to say has them on, as everyone starts. + remotes: array_key_exists('remotes', $data) ? Remotes::fromArray($data['remotes']) : new Remotes, ); } diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index b26120c..95e0a19 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -285,7 +285,7 @@ public function updatedDraft(): void } $this->lastTypingAt = $now; - $this->writeInBackground('PUT', $this->path().'/typing', null, fn () => null, fn () => null); + $this->writeInBackground('PUT', $this->threadPath().'/typing', null, fn () => null, fn () => null); } /** @@ -299,7 +299,7 @@ public function loadOlder(): void return; } - $json = $this->attempt(fn (Client $devnull): array => $devnull->olderMessages($this->path(), $first->id)); + $json = $this->attempt(fn (Client $devnull): array => $devnull->olderMessages($this->threadPath(), $first->id)); if ($json === null) { return; @@ -399,12 +399,12 @@ public function drop(): void public function stash(): void { - $this->writeInBackground('POST', $this->path().'/archive', null, fn () => $this->back(), fn () => null); + $this->writeInBackground('POST', $this->threadPath().'/archive', null, fn () => $this->back(), fn () => null); } public function pop(): void { - $this->writeInBackground('DELETE', $this->path().'/archive', null, function (): void { + $this->writeInBackground('DELETE', $this->threadPath().'/archive', null, function (): void { $this->thread = $this->thread === null ? null : new Thread(...[...get_object_vars($this->thread), 'isArchived' => false]); }, fn () => null); } @@ -434,7 +434,7 @@ private function answerRequest(string $answer): void { $this->requestError = null; - $this->writeInBackground('POST', $this->path().'/'.$answer, null, function () use ($answer): void { + $this->writeInBackground('POST', $this->threadPath().'/'.$answer, null, function () use ($answer): void { if ($answer === 'drop') { $this->back(); @@ -555,7 +555,7 @@ private function markRead(): void $this->readThroughId = $latest; - $this->writeInBackground('PUT', $this->path().'/read', ['through' => $latest], fn () => $this->refreshUnreadNotifications(), fn () => null); + $this->writeInBackground('PUT', $this->threadPath().'/read', ['through' => $latest], fn () => $this->refreshUnreadNotifications(), fn () => null); } /** @@ -575,14 +575,14 @@ private function showKept(): void } } - private function path(): string + private function threadPath(): string { return Client::threadPath($this->username, $this->org === '' ? null : $this->org); } private function messagesPath(): string { - return $this->path().'/messages'; + return $this->threadPath().'/messages'; } private function isOrg(): bool @@ -769,8 +769,11 @@ private function items(?User $viewer): array $all = [...$this->messages, ...$this->pending]; $items = []; $day = null; - $headShown = false; $status = $this->statusMessage($all, $viewer); + $unread = array_keys(array_filter($all, fn (Message $message): bool => $this->head !== null && ! $message->isPending() && $message->id > $this->head && $message->id <= $this->headUntil && $message->userId !== $viewer?->id && ! $message->isEvent())); + + // As on the web, HEAD only shows when you'd read something above it. + $headAt = ($unread[0] ?? 0) > 0 ? $unread[0] : null; foreach ($all as $index => $message) { $label = $message->createdAt === null ? null : Display::dayLabel($message->createdAt); @@ -780,11 +783,8 @@ private function items(?User $viewer): array $day = $label; } - $isNew = ! $headShown && $index > 0 && $this->head !== null && ! $message->isPending() && $message->id > $this->head && $message->id <= $this->headUntil && $message->userId !== $viewer?->id && ! $message->isEvent(); - - if ($isNew) { - $headShown = true; - $items[] = ['type' => 'head', 'count' => count(array_filter($all, fn (Message $later): bool => $later->id > $this->head && $later->id <= $this->headUntil && $later->userId !== $viewer?->id && ! $later->isEvent()))]; + if ($index === $headAt) { + $items[] = ['type' => 'head', 'count' => count($unread)]; } if ($message->isEvent()) { diff --git a/composer.lock b/composer.lock index 0b36930..988f274 100644 --- a/composer.lock +++ b/composer.lock @@ -3009,12 +3009,12 @@ "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-ui.git", - "reference": "ca9b7ef7cdc93f490e196862e33516855d3975f8" + "reference": "6e923788e2770fe48dd267fc234529d588abe81d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/ca9b7ef7cdc93f490e196862e33516855d3975f8", - "reference": "ca9b7ef7cdc93f490e196862e33516855d3975f8", + "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/6e923788e2770fe48dd267fc234529d588abe81d", + "reference": "6e923788e2770fe48dd267fc234529d588abe81d", "shasum": "" }, "require": { @@ -3073,7 +3073,7 @@ "url": "https://nativephp.com/sponsor" } ], - "time": "2026-10-17T16:26:36+00:00" + "time": "2026-10-19T19:28:33+00:00" }, { "name": "nesbot/carbon", diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php index 133c6e9..e15d33c 100644 --- a/resources/views/native/conversation.blade.php +++ b/resources/views/native/conversation.blade.php @@ -9,52 +9,55 @@ @include('native.partials.error') @include('native.partials.refresh-failed') + {{-- + Each row is a child of the scroll view itself, not of one column inside it: SwiftUI keeps a reader's place by + the rows on screen, and with everything in a single row it held onto the bottom instead, so a new message + pulled someone reading back down to it. + --}} - - @if ($hasOlder) - - ↑ earlier messages - - @endif + @if ($hasOlder) + + ↑ earlier messages + + @endif - @if (! $loaded && $error === null) - - - - @endif + @if (! $loaded && $error === null) + + + + @endif - @if ($loaded && $items === [] && $canPush) - - {{ $isOrg ? 'empty history. say hi to the org' : 'empty history. say hi to @'.$username }} - - @endif + @if ($loaded && $items === [] && $canPush) + + {{ $isOrg ? 'empty history. say hi to the org' : 'empty history. say hi to @'.$username }} + + @endif - @foreach ($items as $item) - @if ($item['type'] === 'day') - - - - {{ $item['label'] }} - - - @elseif ($item['type'] === 'head') - - - HEAD · {{ $item['count'] }} new - + @foreach ($items as $item) + @if ($item['type'] === 'day') + + + + {{ $item['label'] }} - @elseif ($item['type'] === 'event') - - * {{ $item['text'] }} - - @else - @include('native.partials.message-item', $item) - @endif - @endforeach + + @elseif ($item['type'] === 'head') + + + HEAD · {{ $item['count'] }} new + + + @elseif ($item['type'] === 'event') + + * {{ $item['text'] }} + + @else + @include('native.partials.message-item', $item) + @endif + @endforeach - {{-- The bar sits over the end of the thread, so the thread clears it, the home indicator and the typing line. --}} - - + {{-- The bar sits over the end of the thread, so the thread clears it, the home indicator and the typing line. --}} + @if ($loaded) diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php index 4bba452..d400531 100644 --- a/resources/views/native/partials/message-item.blade.php +++ b/resources/views/native/partials/message-item.blade.php @@ -13,7 +13,8 @@ @php($replyAuthor = $message->replyTo === null ? null : ($message->replyTo['user_id'] === $viewer?->id ? 'You' : ($authors[$message->replyTo['user_id']]->name ?? 'Someone'))) @php($replyLane = $message->replyTo === null ? 'primary' : \App\Devnull\Lane::token(($authors[$message->replyTo['user_id']] ?? null)?->lane())) - + {{-- A set width, not a max: a column that only hugs its bubble measures the text on one line and clips it. --}} + @if ($isOrg && ! $mine && $startsRun && $author) @include('native.partials.avatar', ['user' => $author, 'size' => 16]) diff --git a/tests/Feature/Screens/AccountRemotesTest.php b/tests/Feature/Screens/AccountRemotesTest.php index 0c0666a..c473f4c 100644 --- a/tests/Feature/Screens/AccountRemotesTest.php +++ b/tests/Feature/Screens/AccountRemotesTest.php @@ -129,3 +129,14 @@ function meWithRemotes(?array $remotes = []): array expect(app(Account::class)->remotes())->toEqual(new Remotes(requests: true, unreadCount: 4)); }); + +it('keeps Remotes on when devnull is too old to say', function () { + fakeApi([ + 'GET /api/v1/me' => [currentUserJson()], + 'GET /api/v1/saved' => [pageJson([])], + ]); + + finishRefreshes(Native::visit('/profile/saved')); + + expect(app(Account::class)->remotes())->toEqual(new Remotes); +}); diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 7400ba9..04ab605 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -277,3 +277,18 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = expect(sentTo('POST', 'remotes/@ada/archive'))->toHaveCount(1); }); + +it('leaves HEAD out when nothing above it was read, as the web does', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'first']), + remoteMessageJson(['id' => 2, 'body' => 'second']), + ])); + + finishRefreshes(Native::visit('/remotes/conversations/ada'))->assertSee('first')->assertDontSee('HEAD'); +}); + +it('opens the partner profile from the menu, in the tab it is in', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); + + finishRefreshes(Native::visit('/remotes/conversations/ada'))->call('viewProfile')->assertNavigatedTo('/remotes/users/ada'); +}); From 683e2a692d244c654e23dbf05c9b7fb09e6a89ac Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 23:58:09 +0100 Subject: [PATCH 3/5] Start remotes and orgs from the inbox, swipe between its tabs, and swipe to reply Remotes gets the web's two buttons at the top right, with its icons: New org (users-round) and New remote (square-pen). New remote finds a developer by name or handle and opens your conversation with them. New org takes an optional name and people, up to the web's 100 with you, and opens the new org. Someone you can't reach shows the web's reason and can't be picked. Both use the people search and org creation in nunomaduro/dev-null.sh#734. Open, Fetched and Stashed are now pages you swipe between, like the Log and Notifications. The pager's tabs are text only, so Fetched's request count goes in its label. Swiping a message sideways replies to it, as the web does on touch screens: someone else's to the right, your own to the left. The field takes focus. Any other swipe, such as a flick while scrolling, does nothing. The typing line now sits above the composer's border, as on the web, so the Push box is tighter. Stash and Pop in a thread's menu use the web's archive icons. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Client.php | 38 ++++ app/Icons/Lucide.php | 10 + .../Concerns/SearchesPeople.php | 72 +++++++ app/NativeComponents/Conversation.php | 28 ++- app/NativeComponents/NewOrg.php | 144 ++++++++++++++ app/NativeComponents/NewRemote.php | 49 +++++ app/NativeComponents/Remotes.php | 185 ++++++++++++++++-- resources/icons/archive-restore.svg | 1 + resources/icons/archive.svg | 1 + resources/icons/search.svg | 1 + resources/icons/square-pen.svg | 1 + resources/icons/users-round.svg | 1 + resources/views/native/conversation.blade.php | 13 +- resources/views/native/new-org.blade.php | 76 +++++++ resources/views/native/new-remote.blade.php | 28 +++ .../native/partials/candidate-row.blade.php | 25 +++ .../native/partials/message-item.blade.php | 12 +- .../native/partials/people-search.blade.php | 15 ++ .../native/partials/remotes-page.blade.php | 37 ++++ resources/views/native/remotes.blade.php | 71 +++---- routes/mobile.php | 4 + tests/Feature/Screens/ConversationTest.php | 29 +++ tests/Feature/Screens/NewRemoteTest.php | 172 ++++++++++++++++ tests/Feature/Screens/RemotesTest.php | 54 ++++- 24 files changed, 995 insertions(+), 72 deletions(-) create mode 100644 app/NativeComponents/Concerns/SearchesPeople.php create mode 100644 app/NativeComponents/NewOrg.php create mode 100644 app/NativeComponents/NewRemote.php create mode 100644 resources/icons/archive-restore.svg create mode 100644 resources/icons/archive.svg create mode 100644 resources/icons/search.svg create mode 100644 resources/icons/square-pen.svg create mode 100644 resources/icons/users-round.svg create mode 100644 resources/views/native/new-org.blade.php create mode 100644 resources/views/native/new-remote.blade.php create mode 100644 resources/views/native/partials/candidate-row.blade.php create mode 100644 resources/views/native/partials/people-search.blade.php create mode 100644 resources/views/native/partials/remotes-page.blade.php create mode 100644 tests/Feature/Screens/NewRemoteTest.php diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 0c1852b..bf241f7 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -75,6 +75,11 @@ public const string RemotePreferencesPath = 'me/remote-preferences'; + /** Who you could start a remote or an org with, as the web's pickers ask. */ + public const string RemoteCandidatesPath = 'remotes/candidates'; + + public const string OrgsPath = 'remotes/orgs'; + public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} /** @@ -249,6 +254,39 @@ public function remotes(?string $tab = null, ?string $before = null): array return $this->get(self::RemotesPath, ['tab' => $tab, 'before' => $before]); } + /** + * People matching a term, each with the web's reason you can't message + * them, or add them to a new org, when there is one. Never kept: who you + * can reach changes as people follow, block and turn Remotes off. + * + * @return list + */ + public function remoteCandidates(string $term, string $for): array + { + $candidates = $this->json($this->send('GET', self::RemoteCandidatesPath, ['q' => $term, 'for' => $for]))['candidates'] ?? null; + + return array_values(array_map( + fn (array $candidate): array => [ + 'user' => User::fromArray($candidate['user']), + 'reason' => is_string($candidate['reason'] ?? null) ? $candidate['reason'] : null, + ], + array_filter(is_array($candidates) ? $candidates : [], fn (mixed $candidate): bool => is_array($candidate) && is_array($candidate['user'] ?? null)), + )); + } + + /** + * Starts an org, as the web's New org does: you maintain it, and the + * people named contribute. Answers its id. + * + * @param list $members + */ + public function createOrg(?string $name, array $members): string + { + $org = $this->json($this->send('POST', self::OrgsPath, payload: ['name' => $name, 'members' => $members]))['org'] ?? null; + + return is_array($org) && is_string($org['id'] ?? null) ? $org['id'] : ''; + } + /** * The messages of a Remotes thread before one, as the web loads them when * you scroll back. diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 1e94e7a..35a5db1 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -56,6 +56,11 @@ enum Lucide case VolumeX; case Volume2; case Ban; + case Archive; + case ArchiveRestore; + case SquarePen; + case UsersRound; + case Search; // The achievement badges, named as devnull names their icons (see forBadge()). case Terminal; @@ -138,6 +143,11 @@ public function custom(bool $filled = false): ?string self::VolumeX => 'volume-x', self::Volume2 => 'volume-2', self::Ban => 'ban', + self::Archive => 'archive', + self::ArchiveRestore => 'archive-restore', + self::SquarePen => 'square-pen', + self::UsersRound => 'users-round', + self::Search => 'search', self::Terminal => 'terminal', self::FileCode => 'file-code', self::Flame => 'flame', diff --git a/app/NativeComponents/Concerns/SearchesPeople.php b/app/NativeComponents/Concerns/SearchesPeople.php new file mode 100644 index 0000000..85a938d --- /dev/null +++ b/app/NativeComponents/Concerns/SearchesPeople.php @@ -0,0 +1,72 @@ + */ + public array $candidates = []; + + /** + * The term the candidates were found for, so they never show under one they weren't. + */ + public ?string $searched = null; + + /** + * The term as the web searches it, without the @ a handle is typed with. + */ + protected function term(): string + { + return ltrim(trim($this->query), '@'); + } + + /** + * Who could start a remote with you, or join a new org: `$for` is "remote" or "org". + */ + protected function searchPeople(string $for): void + { + $term = $this->term(); + + if ($term === '') { + $this->candidates = []; + $this->searched = null; + + return; + } + + $found = $this->attempt(fn (Client $devnull): array => $devnull->remoteCandidates($term, $for)); + + if ($found === null) { + return; + } + + usort($found, fn (array $a, array $b): int => ($a['reason'] !== null) <=> ($b['reason'] !== null)); + + $this->candidates = $found; + $this->searched = $term; + } + + /** + * @return array{user: User, reason: string|null}|null + */ + protected function candidate(string $username): ?array + { + foreach ($this->candidates as $candidate) { + if ($candidate['user']->username === $username) { + return $candidate; + } + } + + return null; + } +} diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 95e0a19..a139ae8 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -121,6 +121,12 @@ class Conversation extends NativeComponent public ?int $replyToId = null; + /** + * Mounts the composer's field afresh when you pick a message to reply to, so it takes focus and raises the + * keyboard, as the web focuses its field. + */ + public int $composerKey = 0; + /** * The message a long press opened the reactions sheet for. */ @@ -194,8 +200,8 @@ public function navigationOptions(): ?NavBarOptions } $items[] = $this->thread->isArchived - ? NavAction::make('pop')->label('Pop')->icon(ios: Lucide::ArrowUp->ios(), android: Lucide::ArrowUp->android())->press('pop') - : NavAction::make('stash')->label('Stash')->icon(ios: Lucide::FoldVertical->ios(), android: Lucide::FoldVertical->android())->press('stash'); + ? 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'); return $options->action( NavAction::make('conversation-actions') @@ -332,6 +338,24 @@ public function reply(int $id): void { $this->replyToId = $id; $this->sheetOpen = false; + $this->composerKey++; + } + + /** + * Swiping a message sideways replies to it, as on the web's touch screens: someone else's to the right, away from + * its edge, and your own to the left. Any other swipe, as a flick while scrolling, does nothing. + */ + public function swipedMessage(int $id, string $direction): void + { + $message = $this->findMessage($id); + + if ($message === null || $message->isEvent() || $message->isPending()) { + return; + } + + if ($direction === ($message->userId === $this->me()?->id ? 'left' : 'right')) { + $this->reply($id); + } } public function cancelReply(): void diff --git a/app/NativeComponents/NewOrg.php b/app/NativeComponents/NewOrg.php new file mode 100644 index 0000000..e39b694 --- /dev/null +++ b/app/NativeComponents/NewOrg.php @@ -0,0 +1,144 @@ + */ + public array $picked = []; + + public bool $creating = false; + + /** + * Why devnull wouldn't create it, in its words, beside the button as on the web. + */ + public ?string $createError = null; + + public function navTitle(): string + { + return 'New org'; + } + + public function updatedQuery(): void + { + $this->searchPeople('org'); + } + + /** + * Picks someone, or takes them back out. Someone who can't join, or anyone once the org is full, can't be picked. + */ + public function toggle(string $username): void + { + $this->createError = null; + + if ($this->isPicked($username)) { + $this->picked = array_values(array_filter($this->picked, fn (User $person): bool => $person->username !== $username)); + + return; + } + + $candidate = $this->candidate($username); + + if ($candidate === null || $candidate['reason'] !== null || $this->isFull()) { + return; + } + + $this->picked = [...$this->picked, $candidate['user']]; + } + + public function create(): void + { + if ($this->picked === [] || $this->creating) { + return; + } + + $this->creating = true; + $this->createError = null; + + try { + $id = app(Client::class)->createOrg( + trim($this->name) === '' ? null : trim($this->name), + array_map(fn (User $person): string => $person->username, $this->picked), + ); + } catch (Unauthenticated $exception) { + $this->signOut($exception); + + return; + } catch (ApiException $exception) { + $this->createError = $exception->firstError(); + $this->creating = false; + + return; + } catch (KeychainUnavailable $exception) { + $this->createError = $exception->getMessage(); + $this->creating = false; + + return; + } + + $this->replace($this->tabRoot().'/orgs/'.rawurlencode($id)); + } + + public function render(): View + { + $isFull = $this->isFull(); + + return view('native.new-org', [ + 'term' => $this->term(), + 'total' => 1 + count($this->picked), + 'isFull' => $isFull, + 'myColour' => $this->myColour(), + 'rows' => array_map(fn (array $candidate): array => [ + 'user' => $candidate['user'], + 'isPicked' => $this->isPicked($candidate['user']->username), + 'reason' => $candidate['reason'] ?? ($isFull && ! $this->isPicked($candidate['user']->username) ? 'the org is full ('.self::MaxMembers.')' : null), + ], $this->candidates), + ]); + } + + private function isPicked(string $username): bool + { + foreach ($this->picked as $person) { + if ($person->username === $username) { + return true; + } + } + + return false; + } + + private function isFull(): bool + { + return 1 + count($this->picked) >= self::MaxMembers; + } +} diff --git a/app/NativeComponents/NewRemote.php b/app/NativeComponents/NewRemote.php new file mode 100644 index 0000000..d449f44 --- /dev/null +++ b/app/NativeComponents/NewRemote.php @@ -0,0 +1,49 @@ +searchPeople('remote'); + } + + public function pick(string $username): void + { + $candidate = $this->candidate($username); + + if ($candidate === null || $candidate['reason'] !== null) { + return; + } + + $this->replace($this->tabRoot().'/conversations/'.rawurlencode($username), ['user' => $candidate['user']]); + } + + public function render(): View + { + return view('native.new-remote', [ + 'term' => $this->term(), + ]); + } +} diff --git a/app/NativeComponents/Remotes.php b/app/NativeComponents/Remotes.php index f0144ad..c4010ae 100644 --- a/app/NativeComponents/Remotes.php +++ b/app/NativeComponents/Remotes.php @@ -6,12 +6,16 @@ use App\Devnull\Client; use App\Devnull\Data\Conversation as ConversationData; use App\Devnull\Exceptions\ApiException; +use App\Devnull\ShownTabs; +use App\Icons\Lucide; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\TalksToDevnull; use App\NativeComponents\Layouts\TabsLayout; use Illuminate\View\View; 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; @@ -23,6 +27,10 @@ * have, and Fetched, while devnull takes requests at all, the first messages * from people you don't follow, waiting for you to merge or drop them. * + * The tabs are pages you swipe between, as on the Log and Notifications: the + * one you're on and one either side, which show the first few rows kept for + * them. + * * It opens on the inbox as it was last time, then asks devnull behind it, and * again every 15 seconds while it's showing, as the web's inbox polls. Rows * change in place, so the list never jumps: a conversation with a new @@ -42,6 +50,11 @@ class Remotes extends NativeComponent */ public const array Tabs = ['open' => 'Open', 'fetched' => 'Fetched', 'stashed' => 'Stashed']; + /** + * How many kept rows a tab beside the one you're on shows, enough to fill the screen while you swipe to it. + */ + public const int PreviewRows = 8; + /** * 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. @@ -60,6 +73,18 @@ class Remotes extends NativeComponent public string $tab = 'open'; + /** + * Mounts a fresh list for the tab you're on when it changes, at the top. + */ + public int $listKey = 0; + + /** + * The list key of each tab you're not on. + * + * @var array + */ + public array $listKeys = []; + /** * How many requests wait in Fetched, which its tab counts, as the web's does. */ @@ -73,6 +98,7 @@ class Remotes extends NativeComponent public function mount(): void { $this->off = app(Account::class)->remotes() === null; + $this->tab = app(ShownTabs::class)->get($this->me()?->id, 'remotes', array_keys($this->tabs()), 'open'); $this->showKept(); $this->revalidate(); } @@ -93,6 +119,41 @@ public function navTitle(): string return 'Remotes'; } + /** + * The web's buttons at the top of the inbox, with its icons: New org, then New remote. With Remotes off there's + * nothing to start. + */ + public function navigationOptions(): ?NavBarOptions + { + if ($this->off) { + return null; + } + + return NavBarOptions::make() + ->action( + NavAction::make('new-org') + ->icon(ios: Lucide::UsersRound->ios(), android: Lucide::UsersRound->android()) + ->a11yLabel('New org') + ->press('newOrg'), + ) + ->action( + NavAction::make('new-remote') + ->icon(ios: Lucide::SquarePen->ios(), android: Lucide::SquarePen->android()) + ->a11yLabel('New remote') + ->press('newRemote'), + ); + } + + public function newRemote(): void + { + $this->openInTab('/new'); + } + + public function newOrg(): void + { + $this->openInTab('/new-org'); + } + /** * The web's inbox poll, every 15 seconds. A poll that fails says nothing; the next one tries again. */ @@ -137,18 +198,42 @@ protected function refused(ApiException $exception): bool return true; } + /** + * Shows a tab, and the pager follows. The one you're on goes back to its top. + */ public function showTab(string $tab): void { - if (! array_key_exists($tab, $this->tabs()) || $tab === $this->tab) { + if (! array_key_exists($tab, $this->tabs())) { return; } - $this->tab = $tab; - $this->conversations = []; - $this->hasMore = false; - $this->loaded = false; - $this->showKept(); - $this->revalidate(); + if ($tab === $this->tab) { + $this->listKey++; + + return; + } + + $this->switchTo($tab); + } + + /** + * The pager settling on a tab, after a swipe or a tap along the top. The one it's already on changes nothing. + */ + public function showPage(int $index): void + { + $tab = array_keys($this->tabs())[$index] ?? null; + + if ($tab !== null && $tab !== $this->tab) { + $this->switchTo($tab); + } + } + + /** + * A tap on the tab you're on, which the pager sends instead of moving: back to the top of it. + */ + public function backToTop(int $index): void + { + $this->showTab(array_keys($this->tabs())[$index] ?? ''); } public function loadMore(): void @@ -210,14 +295,35 @@ private function tabs(): array return $tabs; } + /** + * The tab you go to keeps the list its first rows were drawn in, which can't have been scrolled, and fills it. + * The one you leave starts again at the top of a fresh one. + */ + private function switchTo(string $tab): void + { + app(ShownTabs::class)->remember($this->me()?->id, 'remotes', $tab); + $this->listKeys[$this->tab] = $this->listKey + 1; + $this->listKey = $this->listKeys[$tab] ?? 0; + $this->tab = $tab; + $this->conversations = []; + $this->hasMore = false; + $this->loaded = false; + $this->error = null; + $this->refreshFailed = false; + $this->showKept(); + $this->revalidate(); + } + /** * Open is the inbox itself, so it's asked for without a tab, as the web's links leave it off. * * @return array */ - private function query(): array + private function query(?string $tab = null): array { - return $this->tab === 'open' ? [] : ['tab' => $this->tab]; + $tab ??= $this->tab; + + return $tab === 'open' ? [] : ['tab' => $tab]; } private function showKept(): void @@ -270,18 +376,71 @@ private static function conversationsFrom(array $json): array public function render(): View { $viewer = $this->me(); + $tabs = $this->tabs(); + $keys = array_keys($tabs); + $index = (int) array_search($this->tab, $keys, true); + $from = max(0, $index - 1); + $to = min(count($keys) - 1, $index + 1); + + $pages = []; + + foreach (array_slice($keys, $from, $to - $from + 1) as $tab) { + $pages[] = $tab === $this->tab + ? $this->page($tab, $this->conversations, $this->loaded, $this->hasMore, $this->error, $this->listKey) + : $this->preview($tab); + } return view('native.remotes', [ - 'viewer' => $viewer, 'myColour' => $this->myColour(), - 'tabs' => $this->tabs(), + 'tabLabels' => array_map( + fn (string $tab): string => $tab === 'fetched' && $this->requestsCount > 0 ? "{$tabs[$tab]} · {$this->requestsCount}" : $tabs[$tab], + $keys, + ), + 'pages' => $pages, + 'pageIndex' => $index, + 'pagesFrom' => $from, + 'pagesTo' => $to, + ]); + } + + /** + * A tab beside the one you're on: the first few rows kept for it, as it will open. + * + * @return array + */ + private function preview(string $tab): array + { + $json = $this->off ? null : $this->cached(Client::RemotesPath, $this->query($tab)); + + return $this->page($tab, array_slice($json === null ? [] : self::conversationsFrom($json), 0, self::PreviewRows), $json !== null, false, null, $this->listKeys[$tab] ?? 0); + } + + /** + * What a tab's page shows. + * + * @param list $conversations + * @return array + */ + private function page(string $tab, array $conversations, bool $loaded, bool $hasMore, ?string $error, int $listKey): array + { + $viewer = $this->me(); + + return [ + 'tab' => $tab, + 'showing' => $tab === $this->tab, + 'listKey' => $listKey, + 'loaded' => $loaded, + 'hasMore' => $hasMore, + 'error' => $error, 'rows' => array_map(fn (ConversationData $conversation): array => [ 'conversation' => $conversation, 'title' => $conversation->title($viewer?->id), 'others' => $conversation->others($viewer?->id), 'preview' => $conversation->lastMessage?->preview($viewer?->id, $conversation->isOrg()) ?? '', 'mine' => $conversation->lastMessage !== null && $conversation->lastMessage->userId === $viewer?->id, - ], $this->conversations), - ]); + 'viewer' => $viewer, + 'myColour' => $this->myColour(), + ], $conversations), + ]; } } diff --git a/resources/icons/archive-restore.svg b/resources/icons/archive-restore.svg new file mode 100644 index 0000000..e93e7c9 --- /dev/null +++ b/resources/icons/archive-restore.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/archive.svg b/resources/icons/archive.svg new file mode 100644 index 0000000..634199f --- /dev/null +++ b/resources/icons/archive.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/search.svg b/resources/icons/search.svg new file mode 100644 index 0000000..d40a1b8 --- /dev/null +++ b/resources/icons/search.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/square-pen.svg b/resources/icons/square-pen.svg new file mode 100644 index 0000000..56ca1e3 --- /dev/null +++ b/resources/icons/square-pen.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/users-round.svg b/resources/icons/users-round.svg new file mode 100644 index 0000000..fc5efe0 --- /dev/null +++ b/resources/icons/users-round.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php index e15d33c..06b2600 100644 --- a/resources/views/native/conversation.blade.php +++ b/resources/views/native/conversation.blade.php @@ -63,13 +63,16 @@ @if ($loaded) - @if ($canPush) - + {{-- Above the bar's line, as the web's sits above its composer, so the field keeps its padding tight. --}} + @if ($typing) {{ $typing }} @endif + @endif + + @if ($canPush) @if ($replyTo) @@ -82,11 +85,13 @@ @endif - + + @include('native.partials.error') + + + + You maintain it. Everyone you add contributes. + {{ $total }}/{{ \App\NativeComponents\NewOrg::MaxMembers }} + + + + + @include('native.partials.people-search', ['placeholder' => 'Find people by name or @handle', 'autofocus' => true]) + + @if ($picked === []) + nobody picked yet + @else + + + @foreach ($picked as $person) + + + @include('native.partials.avatar', ['user' => $person, 'size' => 20]) + {{ $person->handle() }} + + + + @endforeach + + + @endif + + + + + + @if ($term === '') + + Type a name or handle + + @elseif ($searched === $term && $rows === []) + + fatal: nothing matches '{{ $term }}' + + @elseif ($searched === $term) + @foreach ($rows as $row) + @include('native.partials.candidate-row', $row + ['press' => 'toggle']) + @endforeach + @endif + {{-- The bottom bar sits over the end of the list, so the list clears it and the home indicator. --}} + + + + + + + + {{ $createError ?? '' }} + + Create org + + + + + diff --git a/resources/views/native/new-remote.blade.php b/resources/views/native/new-remote.blade.php new file mode 100644 index 0000000..b3c9603 --- /dev/null +++ b/resources/views/native/new-remote.blade.php @@ -0,0 +1,28 @@ +{{-- + The web's New remote picker: a field to find a developer by name or handle, then who matches, the ones you can + message first. Until you type it says what to do, and a term nobody matches answers in git's voice. +--}} + + @include('native.partials.error') + + + @include('native.partials.people-search', ['placeholder' => 'Message a developer', 'autofocus' => true]) + + + + + @if ($term === '') + + Type a name or handle to find a developer + + @elseif ($searched === $term && $candidates === []) + + fatal: nothing matches '{{ $term }}' + + @elseif ($searched === $term) + @foreach ($candidates as $candidate) + @include('native.partials.candidate-row', ['user' => $candidate['user'], 'reason' => $candidate['reason'], 'press' => 'pick']) + @endforeach + @endif + + diff --git a/resources/views/native/partials/candidate-row.blade.php b/resources/views/native/partials/candidate-row.blade.php new file mode 100644 index 0000000..41210f6 --- /dev/null +++ b/resources/views/native/partials/candidate-row.blade.php @@ -0,0 +1,25 @@ +{{-- + Someone a people picker found, as the web's candidate row: their avatar, name and handle, and, when you can't reach + them, why, dimmed. In the org picker a circle on the right fills once they're picked. +--}} +@php($isDisabled = $reason !== null && ! ($isPicked ?? false)) + + + + @include('native.partials.avatar', ['user' => $user, 'size' => 36]) + + + {{ $user->name }} + {{ $user->handle() }}{{ $isDisabled ? ' · '.$reason : '' }} + + @isset($isPicked) + @if (! $isDisabled) + + @if ($isPicked) + + @endif + + @endif + @endisset + + diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php index d400531..92d6ad4 100644 --- a/resources/views/native/partials/message-item.blade.php +++ b/resources/views/native/partials/message-item.blade.php @@ -5,14 +5,19 @@ emoji, yours outlined in your lane and a tap to take it back or add yours. A run ends in the time, or for the message the web gives a status, in that status. - A long press opens the message's reactions and Reply; a double tap toggles a heart. A message still being - pushed takes neither. + A long press opens the message's reactions and Reply; a double tap toggles a heart. Swiping it sideways replies to + it, as the web's touch screens do: someone else's to the right, your own to the left. A message still being pushed + takes none of them, and nothing replies while the thread can't take a message. --}} @php($key = $message->id > 0 ? $message->id : $message->clientId) @php($laneToken = \App\Devnull\Lane::token(($author ?? null)?->lane())) @php($replyAuthor = $message->replyTo === null ? null : ($message->replyTo['user_id'] === $viewer?->id ? 'You' : ($authors[$message->replyTo['user_id']]->name ?? 'Someone'))) @php($replyLane = $message->replyTo === null ? 'primary' : \App\Devnull\Lane::token(($authors[$message->replyTo['user_id']] ?? null)?->lane())) +@php($replyable = ($canPush ?? false) && ! $message->isPending()) + @if ($replyable) + + @endif {{-- A set width, not a max: a column that only hugs its bubble measures the text on one line and clips it. --}} @if ($isOrg && ! $mine && $startsRun && $author) @@ -88,4 +93,7 @@ {{ $message->time() }} @endif + @if ($replyable) + + @endif diff --git a/resources/views/native/partials/people-search.blade.php b/resources/views/native/partials/people-search.blade.php new file mode 100644 index 0000000..f8be822 --- /dev/null +++ b/resources/views/native/partials/people-search.blade.php @@ -0,0 +1,15 @@ +{{-- The web's people search field: a search glyph inside a bordered field, the term typed after it. --}} + + + + + + diff --git a/resources/views/native/partials/remotes-page.blade.php b/resources/views/native/partials/remotes-page.blade.php new file mode 100644 index 0000000..7f65fd9 --- /dev/null +++ b/resources/views/native/partials/remotes-page.blade.php @@ -0,0 +1,37 @@ +{{-- + One tab's page in the Remotes pager (see App\NativeComponents\Remotes::page()): the tab you're on, or the first few + rows kept for one beside it, ready for a swipe. Each tab has its own list, and one you leave starts again at the top + of a fresh one, as on Notifications. Only the tab you're on asks for its next page. +--}} + + + @if (! $page['loaded'] && $page['error'] === null) + @foreach (range(1, 4) as $skeleton) + @include('native.partials.remote-row-loading', ['ref' => ($page['showing'] ? '' : $page['tab'].'-').'loading-'.$skeleton]) + @endforeach + @endif + + @if ($page['loaded'] && $page['rows'] === [] && $page['error'] === null) + @include('native.partials.empty', match ($page['tab']) { + 'stashed' => ['command' => 'nothing stashed', 'message' => 'Stash a conversation from its menu. It pops back into Open when someone writes.'], + 'fetched' => ['command' => 'no requests fetched', 'message' => 'First messages from people you don’t follow wait here until you merge or drop them.'], + default => ['command' => 'no conversations yet', 'message' => 'Start one with New remote, or from a profile with Remote.'], + }) + @endif + + @foreach ($page['rows'] as $row) + @include('native.partials.remote-row', $row) + @endforeach + + @if ($page['loaded'] && $page['hasMore']) + @include('native.partials.remote-row-loading', ['ref' => 'loading-more']) + @endif + + diff --git a/resources/views/native/remotes.blade.php b/resources/views/native/remotes.blade.php index 9845b25..150824a 100644 --- a/resources/views/native/remotes.blade.php +++ b/resources/views/native/remotes.blade.php @@ -1,7 +1,8 @@ {{-- - The web's Remotes inbox: Open, Fetched (while devnull takes requests, counting them) and Stashed along the top, in - your lane's underline, then a row per conversation, the latest first. While you have Remotes off, the tab shows - the web's page for /remotes then, its 404, word for word. + The web's Remotes inbox: Open, Fetched (while devnull takes requests, counting them) and Stashed along the top, then + a row per conversation, the latest first. The tabs are pages you swipe between, as on the Log: the pager draws them + in a full-width split, the underline in your lane follows your finger, and a tap on the tab you're on takes it back + to its top. While you have Remotes off, the tab shows the web's page for /remotes then, its 404, word for word. --}} @if ($off) @@ -15,47 +16,29 @@ @include('native.partials.error') @include('native.partials.refresh-failed') - - @foreach ($tabs as $key => $label) - - - - {{ $label }} - @if ($key === 'fetched' && $requestsCount > 0) - - {{ $requestsCount }} - - @endif - - - - + + @foreach ($pages as $page) + @include('native.partials.remotes-page', ['page' => $page]) @endforeach - - - - - @if (! $loaded && $error === null) - @foreach (range(1, 4) as $skeleton) - @include('native.partials.remote-row-loading', ['ref' => 'loading-'.$skeleton]) - @endforeach - @endif - - @if ($loaded && $rows === [] && $error === null) - @include('native.partials.empty', match ($tab) { - 'stashed' => ['command' => 'nothing stashed', 'message' => 'Stash a conversation from its menu. It pops back into Open when someone writes.'], - 'fetched' => ['command' => 'no requests fetched', 'message' => 'First messages from people you don’t follow wait here until you merge or drop them.'], - default => ['command' => 'no conversations yet', 'message' => 'Start one from a profile with Remote.'], - }) - @endif - - @foreach ($rows as $row) - @include('native.partials.remote-row', $row) - @endforeach - - @if ($loaded && $hasMore) - @include('native.partials.remote-row-loading', ['ref' => 'loading-more']) - @endif - + @endif diff --git a/routes/mobile.php b/routes/mobile.php index fbd4c2c..77ecb95 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -7,6 +7,8 @@ use App\NativeComponents\Launch; use App\NativeComponents\Layouts\TabsLayout; use App\NativeComponents\Log; +use App\NativeComponents\NewOrg; +use App\NativeComponents\NewRemote; use App\NativeComponents\Notifications; use App\NativeComponents\Onboarding; use App\NativeComponents\Profile; @@ -24,6 +26,8 @@ Route::native('/log', Log::class); Route::native('/notifications', Notifications::class); Route::native('/remotes', Remotes::class); + Route::native('/remotes/new', NewRemote::class); + Route::native('/remotes/new-org', NewOrg::class); Route::native('/profile', Profile::class); Route::native('/profile/device', ThisDevice::class); Route::native('/profile/saved', Saved::class); diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 04ab605..448a988 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -160,6 +160,35 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = expect(json_decode(sentTo('POST', 'remotes/@ada/messages')[0], true)['reply_to_id'])->toBe(1); }); +it('replies to a message swiped away from its edge, as the web does on a touch screen, and raises the keyboard', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), + remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), + ], ['read_through_id' => 2])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + expect(nodeByRef($screen, 'message-input')['props'])->not->toHaveKey('autofocus'); + + // Theirs goes right; a swipe the other way, or a flick while scrolling, does nothing. + $screen->swipe('swipe-1', 'left')->swipe('swipe-1', 'up')->assertSet('replyToId', null); + $screen->swipe('swipe-1', 'right')->assertSet('replyToId', 1)->assertSee('Replying to Ada Lovelace'); + + expect(nodeByRef($screen, 'message-input')['props']['autofocus'] ?? null)->toBeTrue(); + + // Yours goes left. + $screen->swipe('swipe-2', 'right')->assertSet('replyToId', 1); + $screen->swipe('swipe-2', 'left')->assertSet('replyToId', 2)->assertSee('Replying to yourself'); +}); + +it('takes no swipe to reply while the thread cannot take a message', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1, 'access' => 'request'])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'swipe-1'))->toBeNull(); +}); + it('tells the others you are typing at most every three seconds', function () { serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); diff --git a/tests/Feature/Screens/NewRemoteTest.php b/tests/Feature/Screens/NewRemoteTest.php new file mode 100644 index 0000000..5ac9580 --- /dev/null +++ b/tests/Feature/Screens/NewRemoteTest.php @@ -0,0 +1,172 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * @return array{user: array, reason: string|null} + */ +function candidateJson(string $username, string $name, ?string $reason = null): array +{ + return ['user' => userJson(['id' => 'u-'.$username, 'username' => $username, 'name' => $name]), 'reason' => $reason]; +} + +/** + * Answers people searches with $candidates, and a new org with $org. + * + * @param list> $candidates + * @param array{0: array, 1: int} $org + */ +function servePeople(array $candidates, array $org = [['org' => ['id' => 'org-new']], 201]): void +{ + serveApi(function (Request $request) use ($candidates, $org) { + return match (parse_url($request->url(), PHP_URL_PATH)) { + '/api/v1/remotes/candidates' => Http::response(['candidates' => $candidates]), + '/api/v1/remotes/orgs' => Http::response(...$org), + default => Http::response(remotesInboxJson([])), + }; + }); +} + +function navActionIds(TestableComponent $screen): array +{ + return array_map( + fn (array $node): string => $node['props']['id'], + findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'top_bar_action'), + ); +} + +it('has the web inbox buttons on Remotes, New org then New remote, with its icons', function (string $platform) { + fakeApi(['GET /api/v1/remotes' => [remotesInboxJson([])]]); + + $screen = finishRefreshes(Native::visit('/remotes', platform: $platform)); + + expect(navActionIds($screen))->toBe(['new-org', 'new-remote']); + + $actions = array_column(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'top_bar_action'), 'props'); + + expect($actions[0])->toMatchArray(['icon' => 'custom:users-round', 'a11y_label' => 'New org']) + ->and($actions[1])->toMatchArray(['icon' => 'custom:square-pen', 'a11y_label' => 'New remote']); + + $screen->press('newRemote')->assertNavigatedTo('/remotes/new'); + finishRefreshes(Native::visit('/remotes'))->press('newOrg')->assertNavigatedTo('/remotes/new-org'); +})->with(['ios', 'android']); + +it('has nothing to start while you have Remotes off', function () { + app(Account::class)->rememberRemotes(null); + fakeApi([]); + + expect(navActionIds(Native::visit('/remotes')))->toBe([]); +}); + +it('finds a developer to message as you type, the ones you can message first, as the web picker does', function () { + servePeople([ + candidateJson('grace', 'Grace Hopper', "doesn't follow you"), + candidateJson('ada', 'Ada Lovelace'), + ]); + + $screen = Native::visit('/remotes/new') + ->assertNavTitle('New remote') + ->assertSee('Type a name or handle to find a developer') + ->set('query', '@ad') + ->assertSee('Ada Lovelace') + ->assertSee("@grace · doesn't follow you"); + + expect(childRefs($screen, 'candidates'))->toBe(['candidate-ada', 'candidate-grace']); + + $sent = collect(Http::recorded())->map(fn (array $call): string => $call[0]->url())->filter(fn (string $url): bool => str_contains($url, 'candidates'))->first(); + parse_str((string) parse_url($sent, PHP_URL_QUERY), $query); + + expect($query)->toBe(['q' => 'ad', 'for' => 'remote']); + + // Someone you can't message stays put; anyone else opens your conversation in the picker's place. + $screen->tap('candidate-grace')->assertNavTitle('New remote'); + $screen->tap('candidate-ada')->assertReplacedWith('/remotes/conversations/ada'); +}); + +it("answers a term nobody matches in git's voice", function () { + servePeople([]); + + Native::visit('/remotes/new')->set('query', 'zz')->assertSee("fatal: nothing matches 'zz'"); +}); + +it('picks people for a new org, counts them, and takes one back out from their chip', function () { + servePeople([candidateJson('ada', 'Ada Lovelace'), candidateJson('grace', 'Grace Hopper', 'has remotes off')]); + + $screen = Native::visit('/remotes/new-org') + ->assertNavTitle('New org') + ->assertSee('nobody picked yet') + ->assertSee('1/100') + ->set('query', 'a'); + + $sent = collect(Http::recorded())->map(fn (array $call): string => $call[0]->url())->filter(fn (string $url): bool => str_contains($url, 'candidates'))->first(); + + expect($sent)->toContain('for=org'); + + $screen->tap('candidate-ada')->assertSee('2/100')->assertDontSee('nobody picked yet'); + $screen->tap('candidate-grace')->assertSee('2/100'); + + expect(childRefs($screen, 'picked'))->not->toBeEmpty(); + + $screen->tap('picked-ada')->assertSee('1/100')->assertSee('nobody picked yet'); +}); + +it('says the org is full beside everyone else once it holds the web limit', function () { + servePeople([candidateJson('ada', 'Ada Lovelace')]); + + $people = array_map(fn (int $index): User => User::fromArray(userJson(['id' => "u{$index}", 'username' => "dev{$index}", 'name' => "Dev {$index}"])), range(1, NewOrg::MaxMembers - 1)); + + Native::visit('/remotes/new-org') + ->set('picked', $people) + ->set('query', 'ada') + ->assertSee('100/100') + ->assertSee('@ada · the org is full (100)') + ->tap('candidate-ada') + ->assertSee('100/100'); +}); + +it('creates the org with its name and people, and opens it in the form’s place', function () { + servePeople([candidateJson('ada', 'Ada Lovelace'), candidateJson('grace', 'Grace Hopper')]); + + Native::visit('/remotes/new-org') + ->set('name', ' dev/null core ') + ->set('query', 'a') + ->tap('candidate-ada') + ->tap('candidate-grace') + ->tap('create-org') + ->assertReplacedWith('/remotes/orgs/org-new'); + + expect(json_decode(sentTo('POST', 'remotes/orgs')[0], true))->toBe(['name' => 'dev/null core', 'members' => ['ada', 'grace']]); +}); + +it("shows devnull's reason beside Create org when it won't create it, and stays", function () { + servePeople([candidateJson('ada', 'Ada Lovelace')], [['message' => "@ada doesn't follow you.", 'errors' => ['members.0' => ["@ada doesn't follow you."]]], 422]); + + Native::visit('/remotes/new-org') + ->set('query', 'ada') + ->tap('candidate-ada') + ->tap('create-org') + ->assertSee("@ada doesn't follow you.") + ->assertSet('creating', false) + ->assertNavTitle('New org'); + + expect(sentTo('POST', 'remotes/orgs'))->toHaveCount(1); +}); + +it('creates nothing until someone is picked', function () { + servePeople([]); + + Native::visit('/remotes/new-org')->call('create'); + + expect(sentTo('POST', 'remotes/orgs'))->toBe([]); +}); diff --git a/tests/Feature/Screens/RemotesTest.php b/tests/Feature/Screens/RemotesTest.php index d11ac48..f5ace0e 100644 --- a/tests/Feature/Screens/RemotesTest.php +++ b/tests/Feature/Screens/RemotesTest.php @@ -111,13 +111,53 @@ $screen = finishRefreshes(Native::visit('/remotes')); - expect(childRefs($screen, 'remotes-tabs'))->toBe($requests ? ['tab-open', 'tab-fetched', 'tab-stashed'] : ['tab-open', 'tab-stashed']); - - if ($requests) { - expect(nodeByRef($screen, 'requests-count'))->not->toBeEmpty(); - } + // The pager draws them along the top, as on the Log, with the count in Fetched's label. + expect(nodeByRef($screen, 'remotes-pager')['props']['tabs'])->toBe($requests ? ['Open', 'Fetched · 2', 'Stashed'] : ['Open', 'Stashed']); })->with(['requests on' => true, 'requests off' => false]); +it('swipes between the tabs as the Log does, and opens on the one you had last time', function () { + app(Account::class)->rememberRemotes(new Remotes(requests: true)); + serveApi(function (Request $request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return Http::response(remotesInboxJson(($query['tab'] ?? 'open') === 'stashed' ? [conversationJson(['is_archived' => true])] : [])); + }); + + $screen = finishRefreshes(Native::visit('/remotes')); + $pager = fn (): array => nodeByRef($screen, 'remotes-pager'); + + expect($pager()['props'])->toMatchArray(['horizontal' => true, 'count' => 3, 'page' => 0, 'window_from' => 0, 'window_to' => 1, 'report_on_settle' => true]) + ->and($pager()['props'])->toHaveKey('on_tab_reselect'); + + finishRefreshes($screen->changeTab('showPage', 2))->assertSet('tab', 'stashed')->assertSee('Ada Lovelace'); + + expect($pager()['props'])->toMatchArray(['page' => 2, 'window_from' => 1, 'window_to' => 2]); + + // Switching to another tab at the bottom and back mounts Remotes afresh, on Stashed. + fakeApi(['GET /api/v1/remotes' => [remotesInboxJson([conversationJson(['is_archived' => true])])]]); + + finishRefreshes(Native::visit('/remotes'))->assertSet('tab', 'stashed'); +}); + +it('shows the first rows kept for the tab beside the one you are on, ready for a swipe', function () { + app(Account::class)->rememberRemotes(new Remotes(requests: false)); + serveApi(function (Request $request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return Http::response(remotesInboxJson(($query['tab'] ?? 'open') === 'stashed' + ? [conversationJson(['id' => 'conv-stashed', 'is_archived' => true])] + : [conversationJson(['id' => 'conv-open', 'partner' => userJson(['id' => 'u-grace', 'username' => 'grace', 'name' => 'Grace Hopper'])])])); + }); + + // Stashed has been seen before, so it was kept. + $screen = finishRefreshes(Native::visit('/remotes')); + finishRefreshes($screen->changeTab('showPage', 1)); + finishRefreshes($screen->changeTab('showPage', 0))->assertSet('tab', 'open'); + + expect(nodeByRef($screen, 'remotes'))->not->toBeEmpty() + ->and(childRefs($screen, 'remotes-stashed'))->toBe(['remote-conv-stashed']); +}); + it('lists the stashed conversations and the fetched requests on their tabs, with their own empty states', function () { app(Account::class)->rememberRemotes(new Remotes(requests: true)); serveApi(function (Request $request) { @@ -128,8 +168,8 @@ $screen = finishRefreshes(Native::visit('/remotes'))->assertSee('no conversations yet'); - finishRefreshes($screen->tap('tab-stashed'))->assertSee('Ada Lovelace')->assertSet('tab', 'stashed'); - finishRefreshes($screen->tap('tab-fetched'))->assertSee('no requests fetched'); + finishRefreshes($screen->changeTab('showPage', 2))->assertSee('Ada Lovelace')->assertSet('tab', 'stashed'); + finishRefreshes($screen->changeTab('showPage', 1))->assertSee('no requests fetched'); expect(collect(Http::recorded())->map(fn (array $sent): string => (string) parse_url($sent[0]->url(), PHP_URL_QUERY))->filter()->values()->all()) ->toContain('tab=stashed', 'tab=fetched'); From b5f53318c04db26a6b54ae4108734fbcd8008be4 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 20 Oct 2026 00:21:21 +0100 Subject: [PATCH 4/5] Tap a message for React and Reply, and even out the composer A long press on a message was unreliable, so it's gone. A tap now shows React and Reply beside the message, with the web's icons (smile-plus and reply), as the web shows them on hover. Reply sits nearest the bubble. The row keeps a gutter for them on every message, so they come and go without moving anything. React opens the reactions sheet, which no longer carries Reply. A double tap still toggles a heart, and a swipe still replies. Who's typing moves under the title in the nav bar. The empty typing line above the composer was part of the bar, so it hid messages before they reached the bar's border. The field has the same room above and below, so the bar sits evenly on the keyboard. The thread now pads its end for the bar alone. mobile-ui's bottom anchor adds the home indicator while the keyboard is down (NativePHP/mobile-ui#141, combined/dev-null-app b70ef42). With the keyboard up there's no longer a big gap above the bar, and a short thread no longer floats up the screen. Co-Authored-By: Claude Opus 5.5 --- app/Icons/Lucide.php | 4 ++ app/NativeComponents/Conversation.php | 63 +++++++++++++++-- composer.lock | 8 +-- resources/icons/reply.svg | 1 + resources/icons/smile-plus.svg | 1 + resources/views/native/conversation.blade.php | 29 +++----- .../native/partials/message-actions.blade.php | 17 +++++ .../native/partials/message-item.blade.php | 28 ++++---- tests/Feature/Screens/ConversationTest.php | 69 +++++++++++++++++-- 9 files changed, 173 insertions(+), 47 deletions(-) create mode 100644 resources/icons/reply.svg create mode 100644 resources/icons/smile-plus.svg create mode 100644 resources/views/native/partials/message-actions.blade.php diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 35a5db1..b65a796 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -61,6 +61,8 @@ enum Lucide case SquarePen; case UsersRound; case Search; + case Reply; + case SmilePlus; // The achievement badges, named as devnull names their icons (see forBadge()). case Terminal; @@ -148,6 +150,8 @@ public function custom(bool $filled = false): ?string self::SquarePen => 'square-pen', self::UsersRound => 'users-round', self::Search => 'search', + self::Reply => 'reply', + self::SmilePlus => 'smile-plus', self::Terminal => 'terminal', self::FileCode => 'file-code', self::Flame => 'flame', diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index a139ae8..2d67d57 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -128,7 +128,24 @@ class Conversation extends NativeComponent public int $composerKey = 0; /** - * The message a long press opened the reactions sheet for. + * The composer bar's height with one line in its field: its top line, then the field and Push with 8 points + * above and below. + */ + public const int ComposerBarHeight = 46; + + /** + * The "Replying to …" bar above the field. + */ + public const int ReplyingBarHeight = 44; + + /** + * The message whose React and Reply show beside it, as the web shows them on hover. A tap on a message shows them, + * and a tap on it again, or on another message, moves them. + */ + public ?int $actionsForId = null; + + /** + * The message React opened the reactions sheet for. */ public ?int $sheetMessageId = null; @@ -317,14 +334,29 @@ public function loadOlder(): void } /** - * A long press on a message: its reactions and Reply, in a sheet. + * A tap on a message shows its React and Reply beside it, or hides them if they were showing. */ - public function openMessage(int $id): void + public function toggleActions(int $id): void + { + $message = $this->findMessage($id); + + if ($message === null || $message->isEvent() || $message->isPending()) { + return; + } + + $this->actionsForId = $this->actionsForId === $id ? null : $id; + } + + /** + * React: the web's reactions for the message, in a sheet. + */ + public function openReactions(int $id): void { if ($this->findMessage($id)?->isEvent() !== false) { return; } + $this->actionsForId = null; $this->sheetMessageId = $id; $this->sheetOpen = true; } @@ -337,6 +369,7 @@ public function messageSheetClosed(): void public function reply(int $id): void { $this->replyToId = $id; + $this->actionsForId = null; $this->sheetOpen = false; $this->composerKey++; } @@ -724,8 +757,18 @@ private function title(): string return $this->thread->orgName ?? ($names === [] ? 'Just you' : implode(', ', $names)); } + /** + * Under the title: who's typing while anyone is, as the web shows above its composer, otherwise the handle, or + * how many are in the org. + */ private function subtitle(): ?string { + $typing = self::typingText($this->thread->typing ?? [], $this->isOrg()); + + if ($typing !== null) { + return $typing; + } + if (! $this->isOrg()) { return '@'.$this->username; } @@ -756,7 +799,7 @@ public function render(): View 'isOrg' => $this->isOrg(), 'access' => $thread->access ?? Thread::Open, 'items' => $this->items($viewer), - 'typing' => self::typingText($thread?->typing ?? [], $this->isOrg()), + 'composerClearance' => self::composerClearance($this->draft, $this->replyToId !== null), 'replyTo' => $this->findMessage($this->replyToId), 'sheetMessage' => $this->findMessage($this->sheetMessageId), 'quickReactions' => $this->quickReactions(), @@ -766,6 +809,18 @@ public function render(): View ]); } + /** + * How far the thread pads its end to clear the composer bar: the bar with one line in its field, the lines the + * draft has grown by, the reply bar while replying, and a little room. The scroll view adds the home indicator + * itself while the keyboard is down, so this is the same either way. + */ + private static function composerClearance(string $draft, bool $replying): int + { + $grownBy = Display::replyBarClearance($draft) - Display::ReplyBarHeight - Display::HomeIndicatorHeight; + + return self::ComposerBarHeight + $grownBy + ($replying ? self::ReplyingBarHeight : 0) + 8; + } + /** * The web's typing line: one person by handle, two by both, more by how many. * diff --git a/composer.lock b/composer.lock index 988f274..b9be9b4 100644 --- a/composer.lock +++ b/composer.lock @@ -3009,12 +3009,12 @@ "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-ui.git", - "reference": "6e923788e2770fe48dd267fc234529d588abe81d" + "reference": "b70ef421173c2c64061588aef7f2c3e2309400b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/6e923788e2770fe48dd267fc234529d588abe81d", - "reference": "6e923788e2770fe48dd267fc234529d588abe81d", + "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/b70ef421173c2c64061588aef7f2c3e2309400b3", + "reference": "b70ef421173c2c64061588aef7f2c3e2309400b3", "shasum": "" }, "require": { @@ -3073,7 +3073,7 @@ "url": "https://nativephp.com/sponsor" } ], - "time": "2026-10-19T19:28:33+00:00" + "time": "2026-10-19T23:20:24+00:00" }, { "name": "nesbot/carbon", diff --git a/resources/icons/reply.svg b/resources/icons/reply.svg new file mode 100644 index 0000000..7d91412 --- /dev/null +++ b/resources/icons/reply.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/smile-plus.svg b/resources/icons/smile-plus.svg new file mode 100644 index 0000000..e525801 --- /dev/null +++ b/resources/icons/smile-plus.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php index 06b2600..6d3b30b 100644 --- a/resources/views/native/conversation.blade.php +++ b/resources/views/native/conversation.blade.php @@ -1,10 +1,9 @@ {{-- A Remotes thread: the messages, oldest first, anchored to the latest at the bottom as a chat is, then the - composer in the bottom bar. The bar keeps one line above the field for who's typing, empty or not, so the field - never moves when someone starts or stops. While the thread can't take a message, the bar says why instead, in - the web's words, with Merge and Drop for a request. + composer in the bottom bar. Who's typing shows under the title in the nav bar, so the bar never changes height + for it. While the thread can't take a message, the bar says why instead, in the web's words, with Merge and Drop + for a request. --}} -@php($composerHeight = \App\Devnull\Display::replyBarClearance($draft) + 24 + ($replyTo ? 44 : 0)) @include('native.partials.error') @include('native.partials.refresh-failed') @@ -56,21 +55,13 @@ @endif @endforeach - {{-- The bar sits over the end of the thread, so the thread clears it, the home indicator and the typing line. --}} - + {{-- The bar sits over the end of the thread, so the thread clears it. The scroll view clears the home indicator. --}} + @if ($loaded) - @if ($canPush) - {{-- Above the bar's line, as the web's sits above its composer, so the field keeps its padding tight. --}} - - @if ($typing) - {{ $typing }} - @endif - - @endif @if ($canPush) @if ($replyTo) @@ -85,12 +76,13 @@ @endif - + {{-- The same room above and below the field, so the bar sits evenly on the keyboard. --}} + @endif - {{-- A long press on a message: the reactions the web offers first, the rest, and Reply. --}} + {{-- React on a message: the reactions the web offers first, then the rest. --}} @if ($sheetMessage) @@ -162,9 +154,6 @@ class="w-full py-[6] text-theme-ink" @endforeach - @if ($canPush) - Reply - @endif @endif diff --git a/resources/views/native/partials/message-actions.blade.php b/resources/views/native/partials/message-actions.blade.php new file mode 100644 index 0000000..7a502e3 --- /dev/null +++ b/resources/views/native/partials/message-actions.blade.php @@ -0,0 +1,17 @@ +{{-- + React and Reply for the message you tapped, as the web shows them beside a bubble on hover: Reply nearest it, React + past it, with the web's icons. The gutter keeps its width for every message, so they come and go without moving + anything. +--}} + + @if ($showsActions) + @foreach ($mine ? ['react', 'reply'] : ['reply', 'react'] as $action) + + + @php($icon = $action === 'reply' ? \App\Icons\Lucide::Reply : \App\Icons\Lucide::SmilePlus) + + + + @endforeach + @endif + diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php index 92d6ad4..ec5c1e9 100644 --- a/resources/views/native/partials/message-item.blade.php +++ b/resources/views/native/partials/message-item.blade.php @@ -5,21 +5,26 @@ emoji, yours outlined in your lane and a tap to take it back or add yours. A run ends in the time, or for the message the web gives a status, in that status. - A long press opens the message's reactions and Reply; a double tap toggles a heart. Swiping it sideways replies to - it, as the web's touch screens do: someone else's to the right, your own to the left. A message still being pushed - takes none of them, and nothing replies while the thread can't take a message. + A tap shows React and Reply beside it, as the web shows them on hover, in a gutter the row always keeps so nothing + moves; a double tap toggles a heart. Swiping it sideways replies to it, as the web's touch screens do: someone + else's to the right, your own to the left. A message still being pushed takes none of them, and nothing reacts or + replies while the thread can't take a message. --}} @php($key = $message->id > 0 ? $message->id : $message->clientId) @php($laneToken = \App\Devnull\Lane::token(($author ?? null)?->lane())) @php($replyAuthor = $message->replyTo === null ? null : ($message->replyTo['user_id'] === $viewer?->id ? 'You' : ($authors[$message->replyTo['user_id']]->name ?? 'Someone'))) @php($replyLane = $message->replyTo === null ? 'primary' : \App\Devnull\Lane::token(($authors[$message->replyTo['user_id']] ?? null)?->lane())) @php($replyable = ($canPush ?? false) && ! $message->isPending()) - +@php($showsActions = $replyable && $actionsForId === $message->id) + + @if ($mine) + @include('native.partials.message-actions') + @endif @if ($replyable) - + @endif {{-- A set width, not a max: a column that only hugs its bubble measures the text on one line and clips it. --}} - + @if ($isOrg && ! $mine && $startsRun && $author) @include('native.partials.avatar', ['user' => $author, 'size' => 16]) @@ -35,7 +40,7 @@ @endif @foreach ($message->images as $index => $image) - + @endforeach @if ($message->body !== '') @@ -44,14 +49,10 @@ @include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour]) @else - + @include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary']) @endif - @elseif ($message->images !== [] && ! $message->isPending()) - - - @endif @if ($message->linkPreview?->isReady()) @@ -96,4 +97,7 @@ @if ($replyable) @endif + @if (! $mine) + @include('native.partials.message-actions') + @endif diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 448a988..013c39b 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -2,6 +2,7 @@ use App\Devnull\Account; use App\Devnull\Data\Remotes; +use App\NativeComponents\Conversation; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Native\Mobile\Testing\Native; @@ -58,9 +59,10 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->assertSee('Today') ->assertSee('pulled 14:05'); - // Theirs at the start of the row, yours at the end, in a thread that opens on its latest message. - expect(nodeByRef($screen, 'message-1')['layout']['justify_content'])->toBe(0) - ->and(nodeByRef($screen, 'message-2')['layout']['justify_content'])->toBe(2) + // Theirs at the start of the row with the gutter for React and Reply after it, yours at the end with the gutter + // before it, in a thread that opens on its latest message. + expect(childRefs($screen, 'message-1'))->toBe(['swipe-1', 'actions-1']) + ->and(childRefs($screen, 'message-2'))->toBe(['actions-2', 'swipe-2']) ->and(nodeByRef($screen, 'thread')['props']['scroll_anchor'] ?? null)->toBe('bottom'); }); @@ -93,6 +95,9 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->assertSee('🚀') ->assertSee('@ada is typing…'); + // Under the title, so the composer never changes height for it. + expect(findNode($screen->tree(), fn (array $node): bool => ($node['props']['nav_subtitle'] ?? null) === '@ada is typing…'))->not->toBeNull(); + $polls = collect(Http::recorded())->map(fn (array $sent): string => (string) parse_url($sent[0]->url(), PHP_URL_QUERY))->filter(fn (string $query): bool => str_contains($query, 'after')); expect($polls->first())->toBe('after=1&reactions_after=4') @@ -144,14 +149,50 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = expect(array_map(fn (string $body): string => json_decode($body, true)['client_id'], sentTo('POST', 'remotes/@ada/messages')))->toBe([$clientId, $clientId]); }); -it('replies to a message from its sheet', function () { - serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?'])], ['read_through_id' => 1])); +it('shows React and Reply beside a tapped message, as the web shows them on hover', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), + remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), + ], ['read_through_id' => 2])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + expect(childRefs($screen, 'actions-1'))->toBe([]); + + // Reply sits nearest the bubble: after theirs, before yours. + $screen->tap('bubble-1'); + expect(childRefs($screen, 'actions-1'))->toBe(['reply-1', 'react-1']); + + $screen->tap('bubble-2'); + expect(childRefs($screen, 'actions-1'))->toBe([]) + ->and(childRefs($screen, 'actions-2'))->toBe(['react-2', 'reply-2']); + + // A second tap puts them away. + $screen->tap('bubble-2'); + expect(childRefs($screen, 'actions-2'))->toBe([]); +}); + +it('opens the reactions from React, without Reply in them', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) - ->call('openMessage', 1) + ->tap('bubble-1') + ->tap('react-1') ->assertSet('sheetOpen', true) - ->tap('reply') + ->assertSet('actionsForId', null); + + expect(childRefs($screen, 'quick-reactions'))->not->toBeEmpty() + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'reply'))->toBeNull(); +}); + +it('replies to a message from its Reply button', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?'])], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) + ->tap('bubble-1') + ->tap('reply-1') ->assertSee('Replying to Ada Lovelace') + ->assertSet('actionsForId', null) ->set('draft', 'yes') ->call('send'); @@ -321,3 +362,17 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = finishRefreshes(Native::visit('/remotes/conversations/ada'))->call('viewProfile')->assertNavigatedTo('/remotes/users/ada'); }); + +it('pads the end of the thread for the composer bar alone, and for the reply bar while replying', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + $clearance = fn (): mixed => nodeByRef($screen, 'composer-clearance')['layout']['height'] ?? null; + + // The scroll view adds the home indicator itself while the keyboard is down, so it isn't counted here. + expect($clearance())->toEqual(Conversation::ComposerBarHeight + 8); + + $screen->tap('bubble-1')->tap('reply-1'); + + expect($clearance())->toEqual(Conversation::ComposerBarHeight + Conversation::ReplyingBarHeight + 8); +}); From 2b3c59475fba154c007caaf0d6222c422d464d3f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 20 Oct 2026 00:32:50 +0100 Subject: [PATCH 5/5] Show React and Reply right beside the tapped bubble, fading in The buttons sat in a gutter at the edge of the screen, away from short messages. Now the bubble and a slot for them share a row, so they sit right against the bubble, after someone else's and before yours. The slot keeps its width, so a long message wraps short of it either way, and nothing moves when they appear. An image-only message keeps them beside its images. Every message keeps its buttons, faded out and tucked 8 points toward the bubble. A tap fades them in and slides them into place over 180ms, and fades out the last message's. Faded out, they take no taps. Co-Authored-By: Claude Opus 5.5 --- .../native/partials/message-actions.blade.php | 19 ++++-- .../native/partials/message-images.blade.php | 4 ++ .../native/partials/message-item.blade.php | 58 ++++++++++++------- tests/Feature/Screens/ConversationTest.php | 29 ++++++---- 4 files changed, 73 insertions(+), 37 deletions(-) create mode 100644 resources/views/native/partials/message-images.blade.php diff --git a/resources/views/native/partials/message-actions.blade.php b/resources/views/native/partials/message-actions.blade.php index 7a502e3..f5ef380 100644 --- a/resources/views/native/partials/message-actions.blade.php +++ b/resources/views/native/partials/message-actions.blade.php @@ -1,10 +1,17 @@ {{-- - React and Reply for the message you tapped, as the web shows them beside a bubble on hover: Reply nearest it, React - past it, with the web's icons. The gutter keeps its width for every message, so they come and go without moving - anything. + React and Reply for the message you tapped, right beside its bubble as the web shows them on hover: Reply nearest + it, React past it, with the web's icons. Every message keeps them, faded out and tucked a few points toward the + bubble, so a tap fades and slides them into place, and the slot never changes width. Faded out, they take no taps. --}} - - @if ($showsActions) + + @foreach ($mine ? ['react', 'reply'] : ['reply', 'react'] as $action) @@ -13,5 +20,5 @@ @endforeach - @endif + diff --git a/resources/views/native/partials/message-images.blade.php b/resources/views/native/partials/message-images.blade.php new file mode 100644 index 0000000..edafe89 --- /dev/null +++ b/resources/views/native/partials/message-images.blade.php @@ -0,0 +1,4 @@ +{{-- A message's images, each a tap away from React and Reply. --}} +@foreach ($message->images as $index => $image) + +@endforeach diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php index ec5c1e9..db1c8fa 100644 --- a/resources/views/native/partials/message-item.blade.php +++ b/resources/views/native/partials/message-item.blade.php @@ -5,8 +5,8 @@ emoji, yours outlined in your lane and a tap to take it back or add yours. A run ends in the time, or for the message the web gives a status, in that status. - A tap shows React and Reply beside it, as the web shows them on hover, in a gutter the row always keeps so nothing - moves; a double tap toggles a heart. Swiping it sideways replies to it, as the web's touch screens do: someone + A tap shows React and Reply right beside the bubble, as the web shows them on hover, in a slot its row always keeps + so nothing moves; a double tap toggles a heart. Swiping it sideways replies to it, as the web's touch screens do: someone else's to the right, your own to the left. A message still being pushed takes none of them, and nothing reacts or replies while the thread can't take a message. --}} @@ -16,10 +16,7 @@ @php($replyLane = $message->replyTo === null ? 'primary' : \App\Devnull\Lane::token(($authors[$message->replyTo['user_id']] ?? null)?->lane())) @php($replyable = ($canPush ?? false) && ! $message->isPending()) @php($showsActions = $replyable && $actionsForId === $message->id) - - @if ($mine) - @include('native.partials.message-actions') - @endif + @if ($replyable) @endif @@ -39,20 +36,42 @@ @endif - @foreach ($message->images as $index => $image) - - @endforeach + {{-- An image-only message keeps React and Reply beside its images; otherwise they go beside the bubble. --}} + @if ($message->images !== [] && $message->body === '') + + @if ($mine) + @include('native.partials.message-actions') + @endif + + @include('native.partials.message-images') + + @if (! $mine) + @include('native.partials.message-actions') + @endif + + @else + @include('native.partials.message-images') + @endif @if ($message->body !== '') - @if ($message->isPending()) - - @include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour]) - - @else - - @include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary']) - - @endif + {{-- The bubble shrinks to wrap short of the slot for React and Reply, which keeps its width either way. --}} + + @if ($mine) + @include('native.partials.message-actions') + @endif + @if ($message->isPending()) + + @include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour]) + + @else + + @include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary']) + + @endif + @if (! $mine) + @include('native.partials.message-actions') + @endif + @endif @if ($message->linkPreview?->isReady()) @@ -97,7 +116,4 @@ @if ($replyable) @endif - @if (! $mine) - @include('native.partials.message-actions') - @endif diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 013c39b..0635bb7 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -59,10 +59,12 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->assertSee('Today') ->assertSee('pulled 14:05'); - // Theirs at the start of the row with the gutter for React and Reply after it, yours at the end with the gutter - // before it, in a thread that opens on its latest message. - expect(childRefs($screen, 'message-1'))->toBe(['swipe-1', 'actions-1']) - ->and(childRefs($screen, 'message-2'))->toBe(['actions-2', 'swipe-2']) + // Theirs at the start of the row with the slot for React and Reply right after the bubble, yours at the end with + // the slot right before it, in a thread that opens on its latest message. + expect(nodeByRef($screen, 'content-1')['layout']['justify_content'])->toBe(0) + ->and(childRefs($screen, 'content-1'))->toBe(['bubble-1', 'actions-1']) + ->and(nodeByRef($screen, 'content-2')['layout']['justify_content'])->toBe(2) + ->and(childRefs($screen, 'content-2'))->toBe(['actions-2', 'bubble-2']) ->and(nodeByRef($screen, 'thread')['props']['scroll_anchor'] ?? null)->toBe('bottom'); }); @@ -156,20 +158,27 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ], ['read_through_id' => 2])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + $buttons = fn (int $id): array => nodeByRef($screen, "actions-{$id}-buttons"); - expect(childRefs($screen, 'actions-1'))->toBe([]); + // Reply sits nearest the bubble: after theirs, before yours. Until a tap they're faded out, tucked toward it. + expect(childRefs($screen, 'actions-1-buttons'))->toBe(['reply-1', 'react-1']) + ->and(childRefs($screen, 'actions-2-buttons'))->toBe(['react-2', 'reply-2']) + ->and($buttons(1)['style']['opacity'])->toEqual(0) + ->and($buttons(1)['props'])->toMatchArray(['translate-x' => -8.0, 'animate-duration' => 180.0]) + ->and($buttons(2)['props']['translate-x'])->toEqual(8); - // Reply sits nearest the bubble: after theirs, before yours. + // A tap fades and slides them into place. $screen->tap('bubble-1'); - expect(childRefs($screen, 'actions-1'))->toBe(['reply-1', 'react-1']); + expect($buttons(1)['style']['opacity'])->toEqual(1) + ->and($buttons(1)['props']['translate-x'])->toEqual(0); $screen->tap('bubble-2'); - expect(childRefs($screen, 'actions-1'))->toBe([]) - ->and(childRefs($screen, 'actions-2'))->toBe(['react-2', 'reply-2']); + expect($buttons(1)['style']['opacity'])->toEqual(0) + ->and($buttons(2)['style']['opacity'])->toEqual(1); // A second tap puts them away. $screen->tap('bubble-2'); - expect(childRefs($screen, 'actions-2'))->toBe([]); + expect($buttons(2)['style']['opacity'])->toEqual(0); }); it('opens the reactions from React, without Reply in them', function () {