From 6e289b1bfa40df1a9e8e396519ff4854d6fa439d Mon Sep 17 00:00:00 2001 From: Rohit Date: Mon, 2 Nov 2026 19:09:28 +0530 Subject: [PATCH] Show an organization's page with its commits and mentions, as the web does Organizations can now post commits, and tapping one's name on its commit opened it as a user, which devnull doesn't have: "No query results for model [App\Models\User]". A commit's author now says whether it's an organization, and its name opens the organization's page. That page was a placeholder saying organization pages are new. It's now laid out like the web's: the avatar as a rounded square ringed in its lane, Share and Follow, the handle marked Organization, what GitHub says about it, how many follow it and its admins, three to a line, each opening their profile. Below are its Commits and the commits that mention it, as pages you swipe between, as on a profile. Commits needs devnull's new organizations/{handle}/commits. Until a server has it, that tab says it didn't load, with the page on the web a tap away, and the header and Mentions carry on. Follow changes straight away, puts itself back if devnull says no, and moves the kept page's count with it. Share opens the share sheet with the page on the web, in Lucide's share-2 as the web draws it. An organization whose admins haven't picked a lane is in Commit Blue on its page and its commits, as the web's organizationLaneColor() draws it, where both used to hash a different id into different lanes. Only GitHub handles its bio mentions open, as on a profile. --- app/Devnull/Client.php | 30 ++ app/Devnull/Data/Organization.php | 47 +++ app/Devnull/Data/User.php | 11 +- app/Devnull/Lane.php | 8 +- app/Devnull/ResponseCache.php | 12 + app/Icons/Lucide.php | 2 + .../Concerns/ShowsCommits.php | 10 +- app/NativeComponents/Organization.php | 389 ++++++++++++++++-- resources/icons/share-2.svg | 1 + resources/views/native/organization.blade.php | 111 ++--- .../partials/organization-header.blade.php | 90 ++++ .../partials/organization-page.blade.php | 48 +++ tests/Feature/Devnull/ResponseCacheTest.php | 15 + tests/Feature/Screens/LogTest.php | 9 + tests/Feature/Screens/OrganizationTest.php | 171 +++++++- 15 files changed, 863 insertions(+), 91 deletions(-) create mode 100644 app/Devnull/Data/Organization.php create mode 100644 resources/icons/share-2.svg create mode 100644 resources/views/native/partials/organization-header.blade.php create mode 100644 resources/views/native/partials/organization-page.blade.php diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 9cb9158b..28d9d7e4 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -564,6 +564,36 @@ public static function connectionsPath(string $username, string $type): string return 'users/'.rawurlencode($username).'/'.($type === 'following' ? 'following' : 'followers'); } + /** + * A tab of an organization's page: the commits it posted, or with 'mentions' the commits that mention it. + * + * @return Page + */ + public function organizationCommits(string $handle, string $tab = 'commits', ?string $cursor = null): Page + { + return Page::fromArray($this->get(self::organizationCommitsPath($handle, $tab), ['cursor' => $cursor]), Commit::fromArray(...)); + } + + /** + * Where an organization's commits are, or with 'mentions' the commits that mention it. + */ + public static function organizationCommitsPath(string $handle, string $tab): string + { + return 'organizations/'.rawurlencode($handle).'/'.($tab === 'mentions' ? 'mentions' : 'commits'); + } + + public function followOrganization(string $handle): void + { + $this->send('POST', 'organizations/'.rawurlencode($handle).'/follow'); + $this->cache->organizationFollowChanged(rawurlencode($handle), true); + } + + public function unfollowOrganization(string $handle): void + { + $this->send('DELETE', 'organizations/'.rawurlencode($handle).'/follow'); + $this->cache->organizationFollowChanged(rawurlencode($handle), false); + } + public function follow(string $username): void { $this->send('POST', 'users/'.rawurlencode($username).'/follow'); diff --git a/app/Devnull/Data/Organization.php b/app/Devnull/Data/Organization.php new file mode 100644 index 00000000..c98b0fc8 --- /dev/null +++ b/app/Devnull/Data/Organization.php @@ -0,0 +1,47 @@ + $admins + */ + public function __construct( + public User $organization, + public int $followersCount = 0, + public array $admins = [], + public bool $isFollowing = false, + ) {} + + /** + * @param array $json + */ + public static function fromArray(array $json): self + { + $data = is_array($json['data'] ?? null) ? $json['data'] : []; + $admins = is_array($data['admins'] ?? null) ? $data['admins'] : []; + + return new self( + organization: User::fromArray(['type' => 'organization', ...$data]), + followersCount: max(0, (int) ($data['followers_count'] ?? 0)), + admins: array_values(array_map(User::fromArray(...), array_filter($admins, fn (mixed $admin): bool => is_array($admin) && is_string($admin['handle'] ?? null) && $admin['handle'] !== ''))), + isFollowing: ($json['is_following'] ?? $data['is_following'] ?? false) === true, + ); + } + + /** + * Followed or unfollowed here before devnull answers, with the count moved to match, as the web does. + */ + public function withFollowing(bool $isFollowing): self + { + $change = $isFollowing === $this->isFollowing ? 0 : ($isFollowing ? 1 : -1); + + return new self($this->organization, max(0, $this->followersCount + $change), $this->admins, $isFollowing); + } +} diff --git a/app/Devnull/Data/User.php b/app/Devnull/Data/User.php index 7a60cbe2..7a928363 100644 --- a/app/Devnull/Data/User.php +++ b/app/Devnull/Data/User.php @@ -26,6 +26,7 @@ public function __construct( public ?string $feedUrl = null, public ?CarbonImmutable $createdAt = null, public ?Live $live = null, + public bool $isOrganization = false, ) {} /** @@ -52,13 +53,14 @@ public static function fromArray(array $data): self feedUrl: self::text($data['feed_url'] ?? null), createdAt: Commit::date($data['created_at'] ?? null), live: Live::fromArray($data['live'] ?? null), + isOrganization: ($data['type'] ?? null) === 'organization', ); } /** * The user in the API's shape, so it can be kept and read back. * - * @return array{id: string, handle: string, name: string, avatar_url: ?string, bio: ?string, lane: int|string|null, company: ?string, location: ?string, website: ?string, github_url: ?string, html_url: ?string, feed_url: ?string, created_at: ?string, live: array{platform: ?string, url: string, thumbnail_url: ?string, ends_at: string}|null} + * @return array{id: string, handle: string, name: string, avatar_url: ?string, bio: ?string, lane: int|string|null, company: ?string, location: ?string, website: ?string, github_url: ?string, html_url: ?string, feed_url: ?string, created_at: ?string, live: array{platform: ?string, url: string, thumbnail_url: ?string, ends_at: string}|null, type: string} */ public function toArray(): array { @@ -77,6 +79,7 @@ public function toArray(): array 'feed_url' => $this->feedUrl, 'created_at' => $this->createdAt?->toIso8601ZuluString(), 'live' => $this->live?->toArray(), + 'type' => $this->isOrganization ? 'organization' : 'user', ]; } @@ -91,11 +94,13 @@ public function isLive(): bool /** * The lane the user picked on the web, a palette lane or a custom * `#rrggbb` colour, or the one devnull gave them. Hashing the id is only - * for a server too old to send it. + * for a server too old to send it. An organization whose admins haven't + * picked one is in Commit Blue, as the web draws it, on its page and on + * its commits alike. */ public function lane(): int|string { - return $this->lane ?? Lane::for($this->id); + return $this->lane ?? ($this->isOrganization ? Lane::Primary : Lane::for($this->id)); } public function handle(): string diff --git a/app/Devnull/Lane.php b/app/Devnull/Lane.php index f6e3b2b6..38c015c5 100644 --- a/app/Devnull/Lane.php +++ b/app/Devnull/Lane.php @@ -26,6 +26,12 @@ final class Lane */ public const string Rail = 'rail'; + /** + * Commit Blue, which an organization is drawn in until its admins pick a lane, as the web's + * organizationLaneColor() draws it. + */ + public const string Primary = 'primary'; + /** * Reads a lane from the API: a palette lane, a custom colour as lowercase * `#rrggbb`, or null when it's missing or neither. @@ -76,7 +82,7 @@ public static function isCustom(int|string|null $lane): bool public static function token(int|string|null $lane): string { return match (true) { - $lane === null => 'primary', + $lane === null, $lane === self::Primary => 'primary', $lane === self::Rail => 'rail', is_string($lane) => self::isCustom($lane) ? self::customToken(strtolower($lane)) : 'primary', default => 'lane-'.$lane, diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 69e96fe0..fadaa8a0 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -491,6 +491,18 @@ public function followChanged(string $username, bool $following): void $this->patchFollowedPeople(rawurldecode($username), $following); } + /** + * You followed or unfollowed an organization: its kept page says so, with its followers counted to match. + */ + public function organizationFollowChanged(string $handle, bool $following): void + { + $kept = $this->get("organizations/{$handle}"); + + if ($kept !== null && ($kept['is_following'] ?? false) !== $following) { + $this->patch("organizations/{$handle}", [], fn (array $json): array => [...self::count($json, 'followers_count', $following ? 1 : -1), 'is_following' => $following]); + } + } + /** * Marks someone followed, or not, wherever a kept list of people says whether you follow them. */ diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 360d30a4..cab46406 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -109,6 +109,7 @@ enum Lucide case Phone; case PhoneOff; case MicOff; + case Share2; /** * The icon the web's inbox draws for a kind of notification. @@ -233,6 +234,7 @@ public function custom(bool $filled = false): ?string self::Phone => 'phone', self::PhoneOff => 'phone-off', self::MicOff => 'mic-off', + self::Share2 => 'share-2', default => null, }; diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index b372ef6f..8a4ca752 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -140,12 +140,20 @@ public function openReplies(string $id): void } /** - * Opens a profile with what a row already knows of its author. + * Opens a profile with what a row already knows of its author. An + * organization that posted the commit opens on its organization page, + * since devnull has no user by its handle. */ public function openProfile(string $username): void { $author = $this->shownAuthor($username); + if ($author?->isOrganization === true) { + $this->openOrganization($username); + + return; + } + $this->openInTab('/users/'.$username, $author === null ? [] : ['user' => $author]); } diff --git a/app/NativeComponents/Organization.php b/app/NativeComponents/Organization.php index 5568f9a1..c2f3718c 100644 --- a/app/NativeComponents/Organization.php +++ b/app/NativeComponents/Organization.php @@ -2,74 +2,269 @@ namespace App\NativeComponents; -use App\Devnull\Data\User; +use App\Devnull\Account; +use App\Devnull\Client; +use App\Devnull\Data\Commit; +use App\Devnull\Data\Mention; +use App\Devnull\Data\Organization as OrganizationData; +use App\Devnull\Data\Page; +use App\Devnull\RowCache; use App\Devnull\SiteLink; +use App\Devnull\Stash; +use App\Devnull\Timeline; +use App\NativeComponents\Concerns\DrawsSkeletonFirst; +use App\NativeComponents\Concerns\ExpandsThreads; +use App\NativeComponents\Concerns\HoldsNewCommits; +use App\NativeComponents\Concerns\OpensInTab; +use App\NativeComponents\Concerns\OpensLiveStream; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\ShowsCommits; use App\NativeComponents\Concerns\TalksToDevnull; +use Closure; use Illuminate\View\View; use Native\Mobile\Edge\NativeComponent; use Native\Mobile\Facades\Browser; +use Native\Mobile\Facades\Share; /** - * A GitHub organization someone mentioned, as its page on the web shows it: - * what GitHub says about it, then that organization pages are new. It opens - * from a mention of one, beneath the tab it was tapped in. When devnull - * can't send it, the page on the web is a tap away. + * A GitHub organization's page, as the web shows it at its "@handle": its + * avatar as a rounded square in its lane, Follow and Share, what GitHub says + * about it, how many follow it and its admins, then its Commits and the + * commits that mention it, as pages you swipe between, as on a profile (see + * Profile). * - * devnull sends an organization with the fields a user has, so it's kept as a - * User and drawn with the profile's pieces. It opens on the one kept last - * time, then asks devnull again behind it. + * It opens from a commit the organization posted, or a mention of it, beneath + * the tab it was tapped in, on the page as it was kept last time, then asks + * devnull again behind it. A tab devnull can't send says so on its own page, + * and the header and the other tab carry on. When devnull can't send the + * organization at all, the page on the web is a tap away. */ class Organization extends NativeComponent { + use DrawsSkeletonFirst; + use ExpandsThreads; + use HoldsNewCommits; + use OpensInTab; + use OpensLiveStream; use RefreshesInBackground; + use ShowsCommits; use TalksToDevnull; - public ?User $organization = null; + /** + * Every frame goes in full, as on a profile (see Profile::$forceFullFrames). + */ + protected bool $forceFullFrames = true; + + public const array Tabs = ['commits', 'mentions']; + + /** + * The web's tab labels. + */ + public const array Labels = ['commits' => 'Commits', 'mentions' => 'Mentions']; + + /** + * How many kept commits the tab you're not on shows, as Profile::PreviewCommits. + */ + public const int PreviewCommits = 3; + + public ?OrganizationData $organization = null; + + public string $tab = 'commits'; + + /** + * The list key of the tab you're not on. The tab you're on uses $listKey. + * + * @var array + */ + public array $listKeys = []; + + /** + * Whether the tab has had commits to show, kept or fresh. Until then it shows skeleton rows. + */ + public bool $commitsLoaded = false; + + /** + * devnull didn't send the tab you're on, so it says so instead of its commits. + */ + public bool $commitsFailed = false; + + /** + * Each tab's rows from the last frame, so a render only builds the rows that changed, as on a profile. + * + * @var array + */ + private array $rowCaches = []; public function mount(): void { + $this->fillInAfterFirstFrame('revalidate'); + } + + /** + * Back from a commit or a profile: take any change made there from the cache at once, then ask devnull. + */ + public function onResume(): void + { + $this->forgetRefreshes(); $this->showKept(); $this->revalidate(); } public function navTitle(): string { - return $this->organization->name ?? $this->handle(); + return $this->organization->organization->name ?? $this->handle(); } - public function openWebsite(): void + /** + * Pull to refresh. You're at the top, so new commits go straight in. + */ + public function load(): void { - $url = $this->organization?->websiteUrl(); + $this->refreshOrganization(showNewer: true); + } - if (SiteLink::isWebUrl($url)) { - Browser::inApp($url); + /** + * The pager settling on a tab, after a swipe or a tab tap. The tab it's already on changes nothing. + */ + public function showPage(int $index): void + { + $tab = self::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 + { + if ((self::Tabs[$index] ?? null) === $this->tab) { + $this->listKey++; } } + public function loadMore(): void + { + if ($this->nextCursor === null) { + return; + } + + $page = $this->attempt(fn (Client $devnull) => $devnull->organizationCommits($this->handle(), $this->tab, $this->nextCursor)); + + if ($page !== null) { + $this->commits = [...$this->commits, ...$page->items]; + $this->nextCursor = $page->nextCursor; + } + } + + /** + * Follows or unfollows straight away, and puts it back if devnull says no. + */ + public function toggleFollow(): void + { + if ($this->organization === null) { + return; + } + + $before = $this->organization; + $this->organization = $before->withFollowing(! $before->isFollowing); + + $done = $this->attempt(function (Client $devnull) use ($before): bool { + $before->isFollowing + ? $devnull->unfollowOrganization($this->handle()) + : $devnull->followOrganization($this->handle()); + + return true; + }); + + if ($done !== true) { + $this->organization = $before; + } + } + + /** + * The web's Share beside Follow: the system share sheet, with the organization's page on the web. + */ + public function share(): void + { + Share::url('', '', $this->organization?->organization->htmlUrl ?? $this->webUrl()); + } + + public function openWebsite(): void + { + $this->openUrl($this->organization?->organization->websiteUrl()); + } + public function openOnWeb(): void { - Browser::inApp(rtrim((string) config('devnull.url'), '/').'/@'.rawurlencode($this->handle())); + Browser::inApp($this->webUrl()); } public function openGitHub(): void { - Browser::inApp('https://github.com/'.rawurlencode($this->handle())); + $url = $this->organization?->organization->gitHubProfileUrl() ?? 'https://github.com/'.rawurlencode($this->handle()); + + if (SiteLink::isWebUrl($url)) { + Browser::inApp($url); + } } + /** + * A GitHub handle mentioned in its bio, as on a profile: only one the bio does mention opens. + */ public function openGitHubMention(string $username): void { - Browser::inApp('https://github.com/'.rawurlencode($username)); + foreach (Mention::inText($this->organization->organization->bio ?? '') as $mention) { + if ($mention->username === $username) { + Browser::inApp('https://github.com/'.rawurlencode($username)); + + return; + } + } + } + + /** + * An admin, from the row under the counts, as the web links them. + */ + public function openAdmin(string $username): void + { + foreach ($this->organization->admins ?? [] as $admin) { + if ($admin->username === $username) { + $this->openInTab('/users/'.rawurlencode($username), ['user' => $admin]); + + return; + } + } } public function render(): View { - return view('native.organization', ['handle' => $this->handle()]); + foreach (self::Tabs as $tab) { + $this->rowCache($tab)->startFrame(); + } + + $pages = array_map(fn (string $tab): array => $tab === $this->tab + ? $this->page($tab, $this->commits, $this->nextCursor, $this->commitsLoaded, $this->listKey) + : $this->previewPage($tab), self::Tabs); + + return view('native.organization', [ + 'handle' => $this->handle(), + 'myColour' => $this->myColour(), + 'newCommitsLabel' => $this->newCommitsLabel(), + 'viewer' => $this->me(), + 'stashed' => app(Stash::class)->names($this->me()?->id), + 'pages' => $pages, + 'pageIndex' => array_search($this->tab, self::Tabs, true), + 'tabs' => array_values(self::Labels), + 'pasteis' => app(Account::class)->pasteis(), + ]); } protected function revalidate(): void { - $this->refresh($this->path(), [], $this->take(...)); + $this->refreshOrganization(); } protected function showsContent(): bool @@ -77,26 +272,172 @@ protected function showsContent(): bool return $this->organization !== null; } + /** + * @return iterable + */ + protected function shownCommits(): iterable + { + return [...$this->commits, ...$this->threadCommits()]; + } + + protected function updateShownCommits(Closure $update): void + { + $this->commits = array_map($update, $this->commits); + $this->threads = array_map(fn (array $replies): array => array_map($update, $replies), $this->threads); + } + + /** + * The tab you go to keeps the list its first few commits were drawn in, and fills it. The tab you leave starts + * again at the top of a fresh one. + */ + private function switchTo(string $tab): void + { + $this->listKeys[$this->tab] = $this->listKey + 1; + $this->tab = $tab; + $this->listKey = $this->listKeys[$tab] ?? 0; + $this->commits = []; + $this->threads = []; + $this->nextCursor = null; + $this->commitsLoaded = false; + $this->commitsFailed = false; + $this->forgetNewCommits(); + + if (($kept = $this->cached($this->commitsPath())) !== null) { + $this->takeCommits($tab, $kept); + } + + $this->refreshCommits(); + } + + private function refreshOrganization(bool $showNewer = false): void + { + $this->refresh($this->organizationPath(), [], $this->takeOrganization(...), again: $showNewer); + $this->refreshCommits($showNewer); + $this->refreshThreads(); + } + + /** + * Quiet, so a tab devnull can't send says so on its own page and leaves the header alone. + */ + private function refreshCommits(bool $showNewer = false): void + { + $tab = $this->tab; + + $this->refresh( + $this->commitsPath(), + [], + fn (array $json) => $this->takeCommits($tab, $json, $showNewer), + again: $showNewer, + quiet: true, + failed: function () use ($tab): void { + if ($tab === $this->tab && ! $this->commitsLoaded) { + $this->commitsFailed = true; + } + }, + ); + } + private function showKept(): void { - if (($json = $this->cached($this->path())) !== null) { - $this->take($json); + if (($json = $this->cached($this->organizationPath())) !== null) { + $this->takeOrganization($json); + } + + if (($commits = $this->cached($this->commitsPath())) !== null) { + $this->takeCommits($this->tab, $commits); } } /** * @param array $json */ - private function take(array $json): void + private function takeOrganization(array $json): void { - $this->organization = User::fromArray(is_array($json['data'] ?? null) ? $json['data'] : []); + $organization = OrganizationData::fromArray($json); + + // An answer with no organization in it, like a maintenance page, leaves what's showing alone. + if ($organization->organization->username !== '') { + $this->organization = $organization; + } } - private function path(): string + /** + * @param array $json + */ + private function takeCommits(string $tab, array $json, bool $showNewer = false): void + { + // An answer for the other tab, which you've left. + if ($tab !== $this->tab) { + return; + } + + $this->layFreshCommits($json, $showNewer || ! $this->commitsLoaded); + $this->commitsLoaded = true; + $this->commitsFailed = false; + } + + /** + * The tab you're not on: the first few commits kept for it, as it will open, or skeleton rows with nothing kept. + * + * @return array + */ + private function previewPage(string $tab): array + { + $json = $this->skeletonFrame ? null : $this->cached($this->commitsPath($tab)); + $kept = is_array($json['data'] ?? null) ? $json['data'] : []; + $page = $json === null ? null : Page::fromArray(['data' => array_slice($kept, 0, self::PreviewCommits)] + $json, Commit::fromArray(...)); + + // Rows past the preview are left out, with a skeleton row for them, as for the next page. + $nextCursor = count($kept) > self::PreviewCommits ? 'preview' : $page?->nextCursor; + + return $this->page($tab, $page->items ?? [], $nextCursor, $page !== null, $this->listKeys[$tab] ?? 0); + } + + /** + * What a tab's page shows. + * + * @param list $commits + * @return array + */ + private function page(string $tab, array $commits, ?string $nextCursor, bool $loaded, int $listKey): array + { + $showing = $tab === $this->tab; + + return [ + 'tab' => $tab, + 'rowCache' => $this->rowCache($tab), + 'showing' => $showing, + 'listKey' => $listKey, + 'loaded' => $loaded, + 'failed' => $showing && $this->commitsFailed, + 'isEmpty' => $commits === [], + 'nextCursor' => $nextCursor, + 'rows' => Timeline::feed($commits), + // Replies the tab you're on has opened, under their commits. + 'threads' => $showing ? $this->threads : [], + ]; + } + + private function rowCache(string $tab): RowCache + { + return $this->rowCaches[$tab] ??= new RowCache; + } + + private function commitsPath(?string $tab = null): string + { + return Client::organizationCommitsPath($this->handle(), $tab ?? $this->tab); + } + + private function organizationPath(): string { return 'organizations/'.rawurlencode($this->handle()); } + private function webUrl(): string + { + return rtrim((string) config('devnull.url'), '/').'/@'.rawurlencode($this->handle()); + } + private function handle(): string { return (string) $this->param('handle'); diff --git a/resources/icons/share-2.svg b/resources/icons/share-2.svg new file mode 100644 index 00000000..9fb6280c --- /dev/null +++ b/resources/icons/share-2.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/organization.blade.php b/resources/views/native/organization.blade.php index 326a4e35..f36976c1 100644 --- a/resources/views/native/organization.blade.php +++ b/resources/views/native/organization.blade.php @@ -1,58 +1,63 @@ -{{-- - Laid out like the web's organization page: the avatar as a rounded square, the name, the handle marked - Organization, what GitHub says about it, then the web's note that organization pages are new. devnull's error - isn't printed: the page says itself when it didn't load, and offers the page on the web. ---}} - - - @include('native.partials.refresh-failed') + + @include('native.partials.refresh-failed') + @include('native.partials.commit-sheet') + @include('native.partials.live-stream-sheet') + @include('native.partials.stargazers-sheet') - @if ($organization) - - - {{ mb_strtoupper(mb_substr($organization->name, 0, 1)) }} - @if ($organization->avatarUrl) - - @endif - + {{-- The pager or the list stays alone in here, so the banners above coming and going never add a node beside it. --}} + + @if ($organization) + @php($user = $organization->organization) + {{-- + Commits and Mentions as pages you swipe between, under the tabs the pager draws, as on a profile, with the + header drawn at the top of the page you're on, so it scrolls up with it until the tabs pin under the nav bar. + --}} + + + @include('native.partials.organization-header') + - - {{ $organization->name }} - - {{ '@'.$organization->username }} - - Organization - - - - - @if ($organization->bio) - @include('native.partials.github-text', ['text' => $organization->bio, 'ref' => 'organization-bio', 'classes' => 'w-full text-[15px] leading-relaxed text-theme-ink', 'maxLines' => null]) - @endif + @foreach ($pages as $page) + @include('native.partials.organization-page', ['page' => $page]) + @endforeach - - @if ($organization->location) - @include('native.partials.profile-fact', ['key' => 'location', 'icon' => \App\Icons\Lucide::MapPin, 'label' => $organization->location]) - @endif - @if ($organization->websiteLabel()) - @include('native.partials.profile-fact', ['key' => 'website', 'icon' => \App\Icons\Lucide::Link, 'label' => $organization->websiteLabel(), 'press' => 'openWebsite']) - @endif - @include('native.partials.profile-fact', ['key' => 'github', 'icon' => \App\Icons\Lucide::Github, 'label' => 'github.com/'.$organization->username, 'press' => 'openGitHub']) + + @include('native.partials.new-commits-pill', ['label' => $newCommitsLabel, 'press' => 'showNewCommits', 'shown' => $newCommits !== []]) + + + @else + {{-- devnull's error isn't printed: the page says itself when it didn't load, and offers the page on the web. --}} + + @if ($error) + @include('native.partials.empty', [ + 'command' => '$ git log --grep=@'.$handle, + 'message' => "This organization's page didn't load here.", + 'link' => 'Open it on dev/null', + 'press' => 'openOnWeb', + ]) + @else + + - - - - @include('native.partials.empty', [ - 'command' => '$ git log --grep=@'.$organization->username, - 'message' => "Organization pages are new. Soon you'll be able to follow @{$organization->username} and read what the community commits about it, right here.", - ]) - @elseif ($error) - @include('native.partials.empty', [ - 'command' => '$ git log --grep=@'.$handle, - 'message' => "This organization's page didn't load here.", - 'link' => 'Open it on dev/null', - 'press' => 'openOnWeb', - ]) - @endif + @endif + + @endif - + diff --git a/resources/views/native/partials/organization-header.blade.php b/resources/views/native/partials/organization-header.blade.php new file mode 100644 index 00000000..d0a74b47 --- /dev/null +++ b/resources/views/native/partials/organization-header.blade.php @@ -0,0 +1,90 @@ +{{-- + The organization's header, which the pager draws above its tabs (see organization.blade.php), laid out like the + web's: the avatar as a rounded square ringed in its lane, Share and Follow, the name, the handle marked + Organization, what GitHub says about it, how many follow it and its admins. It scrolls up with the tab you're on + until the tabs pin under the nav bar. +--}} +@php($lane = \App\Devnull\Lane::token($user->lane())) + + + + + + + {{ mb_strtoupper(mb_substr($user->name, 0, 1)) }} + @if ($user->avatarUrl) + + @endif + + + + + + + @if ($organization->isFollowing) + Unfollow + @else + Follow + @endif + + + + + {{ $user->name }} + + + {{ $user->handle() }} + + Organization + + + + + @if ($user->bio) + @include('native.partials.github-text', ['text' => $user->bio, 'ref' => 'organization-bio', 'classes' => 'w-full text-[15px] leading-relaxed text-theme-ink', 'maxLines' => null]) + @endif + + {{-- What GitHub says about it, links in Commit Blue, each on its own line, as on a profile. --}} + + @if ($user->location) + @include('native.partials.profile-fact', ['key' => 'location', 'icon' => \App\Icons\Lucide::MapPin, 'label' => $user->location]) + @endif + @if ($user->websiteLabel()) + @include('native.partials.profile-fact', ['key' => 'website', 'icon' => \App\Icons\Lucide::Link, 'label' => $user->websiteLabel(), 'press' => 'openWebsite']) + @endif + @include('native.partials.profile-fact', ['key' => 'github', 'icon' => \App\Icons\Lucide::Github, 'label' => 'github.com/'.$user->username, 'press' => 'openGitHub']) + + + + {{ \App\Devnull\Display::compactCount($organization->followersCount) }} + {{ $organization->followersCount === 1 ? 'follower' : 'followers' }} + + + {{-- + Its admins, first one first, each opening their profile. The web wraps them onto as many lines as they need; + EDGE's rows don't wrap, so here they go three to a line under the label, a long handle trailing off. + --}} + @if ($organization->admins !== []) + + Admins + @foreach (array_chunk($organization->admins, 3) as $line) + + @foreach ($line as $admin) + + + @include('native.partials.avatar', ['user' => $admin, 'size' => 20]) + {{ $admin->handle() }} + + + @endforeach + {{-- A short last line keeps its admins in the same columns as the lines above. --}} + @for ($gap = count($line); $gap < 3; $gap++) + + @endfor + + @endforeach + + @endif + + + diff --git a/resources/views/native/partials/organization-page.blade.php b/resources/views/native/partials/organization-page.blade.php new file mode 100644 index 00000000..c97e6bee --- /dev/null +++ b/resources/views/native/partials/organization-page.blade.php @@ -0,0 +1,48 @@ +{{-- + One tab's page in the organization's pager (see App\NativeComponents\Organization::page()): the tab you're on, or + the first few commits kept for the other, ready for a swipe. As on a profile, each tab has its own list, a new + $page['listKey'] puts it back at the top, and only the tab you're on asks for its next page. +--}} + + + @if ($page['failed']) + @include('native.partials.empty', [ + 'command' => $page['tab'] === 'mentions' ? '$ git log --grep='.$user->handle() : '$ git log --author='.$user->handle(), + 'message' => $page['tab'] === 'mentions' ? "Its mentions didn't load here." : "Its commits didn't load here.", + 'link' => 'Open it on dev/null', + 'press' => 'openOnWeb', + ]) + @elseif (! $page['loaded']) + @foreach (range(1, 3) as $skeleton) + @include('native.partials.rail-loading', ['ref' => 'loading-'.$skeleton, 'label' => 'Loading commits', 'pulse' => $page['showing']]) + @endforeach + @elseif ($page['isEmpty']) + @if ($page['tab'] === 'mentions') + @include('native.partials.empty', ['command' => '$ git log --grep='.$user->handle(), 'message' => "No one has mentioned {$user->handle()} yet."]) + @else + @include('native.partials.empty', ['command' => 'fatal: no commits yet', 'message' => "{$user->handle()} hasn't committed anything yet."]) + @endif + @endif + + @if (! $page['failed']) + @include('native.partials.feed', ['rows' => $page['rows'], 'threads' => $page['threads'], 'rowCache' => $page['rowCache']]) + + @if ($page['loaded'] && ! $page['isEmpty']) + @if ($page['nextCursor'] !== null) + @include('native.partials.rail-loading', ['pulse' => $page['showing']]) + @elseif ($page['tab'] === 'mentions') + @include('native.partials.rail-end', ['lane' => $user->lane(), 'message' => "That's the first mention of {$user->handle()}."]) + @else + @include('native.partials.rail-end', ['lane' => $user->lane(), 'message' => "That's {$user->handle()}'s root commit."]) + @endif + @endif + @endif + + diff --git a/tests/Feature/Devnull/ResponseCacheTest.php b/tests/Feature/Devnull/ResponseCacheTest.php index 8ca06cae..7ba55066 100644 --- a/tests/Feature/Devnull/ResponseCacheTest.php +++ b/tests/Feature/Devnull/ResponseCacheTest.php @@ -307,6 +307,21 @@ ->and($this->cache->get('users/local_dev')['data']['following_count'])->toBe(2); }); +it('follows and unfollows on a kept organization page', function () { + $this->cache->put('organizations/laravel', [], ['data' => ['handle' => 'laravel', 'followers_count' => 45], 'is_following' => false]); + + $this->cache->organizationFollowChanged('laravel', true); + $this->cache->organizationFollowChanged('laravel', true); + + expect($this->cache->get('organizations/laravel'))->toMatchArray(['is_following' => true]) + ->and($this->cache->get('organizations/laravel')['data']['followers_count'])->toBe(46); + + $this->cache->organizationFollowChanged('laravel', false); + + expect($this->cache->get('organizations/laravel'))->toMatchArray(['is_following' => false]) + ->and($this->cache->get('organizations/laravel')['data']['followers_count'])->toBe(45); +}); + it('counts a follow on your profile when theirs was never kept', function () { $this->cache->put('users/local_dev', [], ['data' => userWithCountsJson(following: 2), 'is_following' => false]); diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 9aff6fb6..6ea91a05 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -127,6 +127,15 @@ Native::visit('/log')->call('openProfile', 'ada')->assertNavigatedTo('/log/users/ada'); }); +it('opens an organization that posted a commit on its organization page', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ + 'id' => 'c1', + 'author' => userJson(['type' => 'organization', 'handle' => 'dev-null-sh', 'name' => 'dev-null.sh']), + ])]))]); + + Native::visit('/log')->press("openProfile('dev-null-sh')")->assertNavigatedTo('/log/organizations/dev-null-sh'); +}); + it('sends you back to onboarding with the server message on a 401', function () { Http::fake(['devnull.test/*' => Http::response(['message' => 'This key is not on any devnull account.', 'code' => 'key_not_on_github'], 401)]); diff --git a/tests/Feature/Screens/OrganizationTest.php b/tests/Feature/Screens/OrganizationTest.php index 867524cd..2a8fc540 100644 --- a/tests/Feature/Screens/OrganizationTest.php +++ b/tests/Feature/Screens/OrganizationTest.php @@ -1,22 +1,57 @@ 'http://devnull.test']); signIn(); }); +function organizationJson(array $overrides = [], bool $isFollowing = false): array +{ + return [ + 'data' => [ + 'handle' => 'laravel', + 'name' => 'Laravel', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072', + 'bio' => 'The PHP framework for web artisans.', + 'location' => null, + 'website' => 'https://laravel.com', + 'github_url' => 'https://github.com/laravel', + 'html_url' => 'http://devnull.test/@laravel', + 'lane' => 0, + 'followers_count' => 45, + 'admins' => [ + userJson(['id' => 'u-taylor', 'handle' => 'taylorotwell', 'name' => 'Taylor Otwell']), + userJson(['id' => 'u-nuno', 'handle' => 'nunomaduro', 'name' => 'Nuno Maduro']), + ], + ...$overrides, + ], + 'is_following' => $isFollowing, + 'can_claim' => false, + 'settings_url' => null, + ]; +} + +function organizationCommitJson(array $overrides = []): array +{ + return commitJson(['author' => ['type' => 'organization', 'id' => 'o-laravel', 'handle' => 'laravel', 'name' => 'Laravel', 'lane' => 0], ...$overrides]); +} + +function tapOrganizationTab(TestableComponent $screen, string $tab): TestableComponent +{ + return $screen->changeTab($screen->get('tab') === $tab ? 'backToTop' : 'showPage', (int) array_search($tab, Organization::Tabs, true)); +} + it('shows an organization as its page on the web does, and opens its links in the browser', function () { - Http::fake(['devnull.test/api/v1/organizations/laravel' => Http::response(['data' => [ - 'handle' => 'laravel', - 'name' => 'Laravel', - 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072', - 'bio' => 'The PHP framework for web artisans.', - 'location' => null, - 'website' => 'https://laravel.com', - ]])]); + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson()], + 'GET /api/v1/organizations/laravel/commits' => [pageJson([organizationCommitJson(['id' => 'c1', 'body' => 'Laravel 13 is out'])])], + ]); $screen = finishRefreshes(Native::visit('/log/organizations/laravel')); @@ -28,13 +63,97 @@ ->assertSee('The PHP framework for web artisans.') ->assertSee('laravel.com') ->assertSee('github.com/laravel') - ->assertSee('$ git log --grep=@laravel') + ->assertSee('45')->assertSee('followers') + ->assertSee('Admins')->assertSee('@taylorotwell')->assertSee('@nunomaduro') + ->assertElement('pager', fn (array $node): bool => ($node['props']['tabs'] ?? null) === ['Commits', 'Mentions']) + ->assertSee('Laravel 13 is out') + ->assertSee("That's @laravel's root commit.") ->tap('profile-website') ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://laravel.com') ->tap('profile-github') ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/laravel'); }); +it('lists the commits that mention the organization on its Mentions tab', function () { + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson()], + 'GET /api/v1/organizations/laravel/commits' => [pageJson([])], + 'GET /api/v1/organizations/laravel/mentions' => [pageJson([commitJson(['id' => 'm1', 'body' => 'Thanks @laravel'])])], + ]); + + $screen = finishRefreshes(Native::visit('/log/organizations/laravel')) + ->assertSee("@laravel hasn't committed anything yet."); + + finishRefreshes(tapOrganizationTab($screen, 'mentions')) + ->assertSet('tab', 'mentions') + ->assertSee('Thanks @laravel') + ->assertSee("That's the first mention of @laravel."); +}); + +it('says a tab did not load without losing the header', function () { + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson()], + 'GET /api/v1/organizations/laravel/commits' => [['message' => 'Not Found.'], 404], + ]); + + finishRefreshes(Native::visit('/log/organizations/laravel')) + ->assertSee('The PHP framework for web artisans.') + ->assertSee("Its commits didn't load here.") + ->assertDontSee('Not Found.') + ->tap('empty-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/@laravel'); +}); + +it('follows and unfollows the organization', function () { + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson()], + 'GET /api/v1/organizations/laravel/commits' => [pageJson([])], + 'POST /api/v1/organizations/laravel/follow' => [null, 204], + 'DELETE /api/v1/organizations/laravel/follow' => [null, 204], + ]); + + $screen = finishRefreshes(Native::visit('/log/organizations/laravel')) + ->tap('follow') + ->assertSee('Unfollow') + ->assertSee('46'); + + expect($screen->instance()->organization->isFollowing)->toBeTrue(); + + $screen->tap('follow'); + + expect($screen->instance()->organization->isFollowing)->toBeFalse() + ->and($screen->instance()->organization->followersCount)->toBe(45); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://devnull.test/api/v1/organizations/laravel/follow'); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && $request->url() === 'http://devnull.test/api/v1/organizations/laravel/follow'); +}); + +it('puts the follow back when devnull refuses it', function () { + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson()], + 'GET /api/v1/organizations/laravel/commits' => [pageJson([])], + 'POST /api/v1/organizations/laravel/follow' => [['message' => 'Too Many Attempts.'], 429], + ]); + + $screen = finishRefreshes(Native::visit('/log/organizations/laravel'))->tap('follow'); + + expect($screen->instance()->organization->isFollowing)->toBeFalse() + ->and($screen->instance()->organization->followersCount)->toBe(45); +}); + +it('shares the organization page and opens its admins', function () { + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson()], + 'GET /api/v1/organizations/laravel/commits' => [pageJson([])], + ]); + + finishRefreshes(Native::visit('/log/organizations/laravel')) + ->tap('share') + ->assertNativeCalled('Share.Url', fn (array $params): bool => $params['url'] === 'http://devnull.test/@laravel') + ->tap('admin-nunomaduro') + ->assertNavigatedTo('/log/users/nunomaduro'); +}); + it("says the page didn't load when devnull can't send the organization, and offers its page on the web", function () { Http::fake(['devnull.test/api/v1/organizations/*' => Http::response(['message' => 'Not Found.'], 404)]); @@ -46,3 +165,37 @@ $screen->tap('empty-link') ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/@nobody'); }); + +it('draws an organization without a lane in Commit Blue on its page and its commits, as the web does', function () { + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson(['lane' => null])], + 'GET /api/v1/organizations/laravel/commits' => [pageJson([organizationCommitJson([ + 'id' => 'c1', + 'author' => ['type' => 'organization', 'id' => '01a19c71-70d7-73e4-801c-09e5c315ad53', 'handle' => 'laravel', 'name' => 'Laravel', 'lane' => null], + ])])], + ]); + + $screen = finishRefreshes(Native::visit('/log/organizations/laravel')); + $primary = strtolower((string) config('native-ui.theme.light.primary')); + $ring = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'stack' && (float) ($node['layout']['width'] ?? 0) === 92.0); + $lanes = array_map(fn (int $lane): string => strtolower((string) config("native-ui.theme.light.lane-{$lane}")), range(0, 5)); + $laneBorders = findNodes($screen->tree(), fn (array $node): bool => in_array(strtolower((string) ($node['style']['border_color'] ?? '')), $lanes, true)); + + expect(strtolower($ring['style']['bg_color']))->toBe($primary) + ->and(findNodes($screen->tree(), fn (array $node): bool => strtolower((string) ($node['style']['border_color'] ?? '')) === $primary))->not->toBeEmpty() + ->and($laneBorders)->toBeEmpty(); +}); + +it('opens only the GitHub handles its bio mentions', function () { + fakeApi([ + 'GET /api/v1/organizations/laravel' => [organizationJson(['bio' => 'Maintained by @taylorotwell'])], + 'GET /api/v1/organizations/laravel/commits' => [pageJson([])], + ]); + + $screen = finishRefreshes(Native::visit('/log/organizations/laravel')) + ->call('openGitHubMention', 'someone-else') + ->assertNativeNotCalled('Browser.OpenInApp'); + + $screen->call('openGitHubMention', 'taylorotwell') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/taylorotwell'); +});