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'); +});