From 8b6cb8df63b27ce167e1ff6d635f140055dcb562 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 29 Oct 2026 00:40:42 +0530 Subject: [PATCH 1/2] Open mentioned GitHub organizations in their own screen A mention now says whether it names a user or a GitHub organization, and tapping @laravel opened a profile that devnull doesn't have: "No query results for model [App\Models\User] laravel". An organization mention now opens an Organization screen beneath the tab, drawn like the web's page from GET /api/v1/organizations/{handle} (dev-null-sh/dev-null.sh#1268). Until devnull has that, the screen offers the page on the web. --- app/Devnull/Data/BodyRun.php | 5 +- app/Devnull/Data/CommitBody.php | 2 +- app/Devnull/Data/Mention.php | 9 +- app/Devnull/ResponseCache.php | 1 + .../Concerns/ShowsCommits.php | 8 ++ app/NativeComponents/Conversation.php | 8 ++ app/NativeComponents/Organization.php | 104 ++++++++++++++++++ resources/views/native/organization.blade.php | 58 ++++++++++ .../native/partials/commit-body.blade.php | 2 +- .../native/partials/message-body.blade.php | 2 +- routes/mobile.php | 2 + tests/Feature/Screens/ConversationTest.php | 12 ++ tests/Feature/Screens/LogTest.php | 23 ++++ tests/Feature/Screens/OrganizationTest.php | 48 ++++++++ 14 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 app/NativeComponents/Organization.php create mode 100644 resources/views/native/organization.blade.php create mode 100644 tests/Feature/Screens/OrganizationTest.php diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index 598c4bf7..59f93131 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -6,11 +6,12 @@ * A run of a paragraph: plain prose, a mention, inline code, a link, or a * standalone `/dev/null`, which draws as the wordmark, or a hex color, which * draws with a swatch of it. A link's text is the URL as written, and a - * color's is the color as written, `#` included. + * color's is the color as written, `#` included. A mention of a GitHub + * organization has its handle in $username too, and $isOrganization set. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null, public bool $isOrganization = false) {} public function isMention(): bool { diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index 26e8f623..ac7ea774 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -324,7 +324,7 @@ private static function withMentions(string $text, int $start, array $mentions, array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors)); } - $runs[] = new BodyRun($handle, username: $mention->username); + $runs[] = new BodyRun($handle, username: $mention->username, isOrganization: $mention->isOrganization); $position = $offset + $mention->length; } diff --git a/app/Devnull/Data/Mention.php b/app/Devnull/Data/Mention.php index 3d2eeb59..966e40a7 100644 --- a/app/Devnull/Data/Mention.php +++ b/app/Devnull/Data/Mention.php @@ -3,12 +3,13 @@ namespace App\Devnull\Data; /** - * Where the body mentions someone. The offset and length count Unicode code - * points into the whole body and include the "@". + * Where the body mentions someone, or a GitHub organization devnull knows. + * The offset and length count Unicode code points into the whole body and + * include the "@". */ final readonly class Mention { - public function __construct(public string $username, public int $offset, public int $length) {} + public function __construct(public string $username, public int $offset, public int $length, public bool $isOrganization = false) {} /** * GitHub accounts tagged in a bio or company, excluding emails, paths and package names. @@ -39,6 +40,6 @@ public static function fromArray(array $data): ?self return null; } - return new self($username, $offset, $length); + return new self($username, $offset, $length, ($data['kind'] ?? null) === 'organization'); } } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 34a9ee1a..534a205f 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -44,6 +44,7 @@ final class ResponseCache '#^commits/[^/]+$#', '#^commits/[^/]+/(replies|related)$#', '#^users/[^/]+$#', + '#^organizations/[^/]+$#', '#^users/[^/]+/commits$#', '#^starred$#', '#^users/[^/]+/(followers|following)$#', diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index e6af916c..9b9c5dc8 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -145,6 +145,14 @@ public function openProfile(string $username): void $this->openInTab('/users/'.$username, $author === null ? [] : ['user' => $author]); } + /** + * Opens a mentioned GitHub organization's page. + */ + public function openOrganization(string $handle): void + { + $this->openInTab('/organizations/'.rawurlencode($handle)); + } + /** * Opens the commit with its reply composer focused. */ diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 5010793b..0041269f 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -574,6 +574,14 @@ public function openProfile(string $username): void $this->openInTab('/users/'.rawurlencode($username)); } + /** + * Opens a mentioned GitHub organization's page. + */ + public function openOrganization(string $handle): void + { + $this->openInTab('/organizations/'.rawurlencode($handle)); + } + /** * Opens a message's link preview: a page on dev/null the app has a screen * for here in the app, in this tab, and anything else in the browser. diff --git a/app/NativeComponents/Organization.php b/app/NativeComponents/Organization.php new file mode 100644 index 00000000..5568f9a1 --- /dev/null +++ b/app/NativeComponents/Organization.php @@ -0,0 +1,104 @@ +showKept(); + $this->revalidate(); + } + + public function navTitle(): string + { + return $this->organization->name ?? $this->handle(); + } + + public function openWebsite(): void + { + $url = $this->organization?->websiteUrl(); + + if (SiteLink::isWebUrl($url)) { + Browser::inApp($url); + } + } + + public function openOnWeb(): void + { + Browser::inApp(rtrim((string) config('devnull.url'), '/').'/@'.rawurlencode($this->handle())); + } + + public function openGitHub(): void + { + Browser::inApp('https://github.com/'.rawurlencode($this->handle())); + } + + public function openGitHubMention(string $username): void + { + Browser::inApp('https://github.com/'.rawurlencode($username)); + } + + public function render(): View + { + return view('native.organization', ['handle' => $this->handle()]); + } + + protected function revalidate(): void + { + $this->refresh($this->path(), [], $this->take(...)); + } + + protected function showsContent(): bool + { + return $this->organization !== null; + } + + private function showKept(): void + { + if (($json = $this->cached($this->path())) !== null) { + $this->take($json); + } + } + + /** + * @param array $json + */ + private function take(array $json): void + { + $this->organization = User::fromArray(is_array($json['data'] ?? null) ? $json['data'] : []); + } + + private function path(): string + { + return 'organizations/'.rawurlencode($this->handle()); + } + + private function handle(): string + { + return (string) $this->param('handle'); + } +} diff --git a/resources/views/native/organization.blade.php b/resources/views/native/organization.blade.php new file mode 100644 index 00000000..ee70bc84 --- /dev/null +++ b/resources/views/native/organization.blade.php @@ -0,0 +1,58 @@ +{{-- + 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. +--}} + + + @include('native.partials.error') + @include('native.partials.refresh-failed') + + @if ($organization) + + + {{ mb_strtoupper(mb_substr($organization->name, 0, 1)) }} + @if ($organization->avatarUrl) + + @endif + + + + {{ $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 + + + @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.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 + + diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 27a1ad91..349ac9e1 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -43,7 +43,7 @@ @endif @foreach ($block->runs as $run) @if ($run->isMention()) - + @elseif ($run->isCode) @elseif ($run->link?->isNamed()) diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php index 35fbc49c..7dbe77e8 100644 --- a/resources/views/native/partials/message-body.blade.php +++ b/resources/views/native/partials/message-body.blade.php @@ -16,7 +16,7 @@ @endif @foreach ($block->runs as $run) @if ($run->isMention()) - + @elseif ($run->isCode) @elseif ($run->isColor()) diff --git a/routes/mobile.php b/routes/mobile.php index 1a5a0ec5..f342f766 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -12,6 +12,7 @@ use App\NativeComponents\NewRemote; use App\NativeComponents\Notifications; use App\NativeComponents\Onboarding; +use App\NativeComponents\Organization; use App\NativeComponents\OrgMembers; use App\NativeComponents\Profile; use App\NativeComponents\Remotes; @@ -40,6 +41,7 @@ foreach (TabsLayout::Tabs as $tab) { Route::native("{$tab}/commits/{id}", CommitDetail::class); Route::native("{$tab}/users/{username}", Profile::class); + Route::native("{$tab}/organizations/{handle}", Organization::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); diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 4e7eef56..918c1d69 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -460,6 +460,18 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->assertNativeCalledTimes('Browser.OpenInApp', 1); }); +it("opens a mentioned organization's page, and a mentioned user's profile, in the tab", function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'ask @laravel or @grace', 'mentions' => [ + ['handle' => 'laravel', 'offset' => 4, 'length' => 8, 'kind' => 'organization'], + ['handle' => 'grace', 'offset' => 16, 'length' => 6, 'kind' => 'user'], + ]]), + ], ['read_through_id' => 1])); + + finishRefreshes(Native::visit('/remotes/conversations/ada'))->press("openOrganization('laravel')")->assertNavigatedTo('/remotes/organizations/laravel'); + finishRefreshes(Native::visit('/remotes/conversations/ada'))->press("openProfile('grace')")->assertNavigatedTo('/remotes/users/grace'); +}); + it('opens no link preview that is not a web page', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'Tap me', 'link_preview' => ['url' => 'javascript:alert(1)', 'title' => 'Tap me']]), diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 5f00b8e5..d1f9f8b2 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -185,6 +185,29 @@ $screen->press("openProfile('Grace-H')")->assertNavigatedTo('/log/users/Grace-H'); }); +it("opens a mentioned organization's page, and a mentioned user's profile, in the tab", function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ + 'id' => 'c1', + 'body' => 'thanks @laravel and @ada', + 'mentions' => [ + ['handle' => 'laravel', 'offset' => 7, 'length' => 8, 'kind' => 'organization'], + ['handle' => 'ada', 'offset' => 20, 'length' => 4, 'kind' => 'user'], + ], + ])]))]); + + $screen = Native::visit('/log'); + + expect(paragraphRuns($screen, 'thanks'))->toBe([ + ['thanks '], + ['@laravel', 'mention'], + [' and '], + ['@ada', 'mention'], + ]); + + $screen->press("openOrganization('laravel')")->assertNavigatedTo('/log/organizations/laravel'); + Native::visit('/log')->press("openProfile('ada')")->assertNavigatedTo('/log/users/ada'); +}); + it('shows inline code on a wash and keeps mentions on both sides of it', function () { Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ 'body' => '@ada ran `composer test` for @bob', diff --git a/tests/Feature/Screens/OrganizationTest.php b/tests/Feature/Screens/OrganizationTest.php new file mode 100644 index 00000000..f442641c --- /dev/null +++ b/tests/Feature/Screens/OrganizationTest.php @@ -0,0 +1,48 @@ + 'http://devnull.test']); + signIn(); +}); + +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', + ]])]); + + $screen = finishRefreshes(Native::visit('/log/organizations/laravel')); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'profile-location'))->toBeNull(); + + $screen->assertNavTitle('Laravel') + ->assertSee('@laravel') + ->assertSee('Organization') + ->assertSee('The PHP framework for web artisans.') + ->assertSee('laravel.com') + ->assertSee('github.com/laravel') + ->assertSee('$ git log --grep=@laravel') + ->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("says what went wrong 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)]); + + $screen = finishRefreshes(Native::visit('/log/organizations/nobody'))->assertNavTitle('nobody'); + + nodeByRef($screen, 'error'); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'organization-header'))->toBeNull(); + + $screen->tap('empty-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/@nobody'); +}); From b171a96c1cddad316321c3bd887a07dfaa5ad491 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 29 Oct 2026 00:46:24 +0530 Subject: [PATCH 2/2] Leave devnull's error off the organization screen The page says itself when it didn't load, and offers the page on the web. --- resources/views/native/organization.blade.php | 4 ++-- tests/Feature/Screens/OrganizationTest.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/resources/views/native/organization.blade.php b/resources/views/native/organization.blade.php index ee70bc84..326a4e35 100644 --- a/resources/views/native/organization.blade.php +++ b/resources/views/native/organization.blade.php @@ -1,10 +1,10 @@ {{-- 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. + 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.error') @include('native.partials.refresh-failed') @if ($organization) diff --git a/tests/Feature/Screens/OrganizationTest.php b/tests/Feature/Screens/OrganizationTest.php index f442641c..867524cd 100644 --- a/tests/Feature/Screens/OrganizationTest.php +++ b/tests/Feature/Screens/OrganizationTest.php @@ -35,13 +35,13 @@ ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/laravel'); }); -it("says what went wrong when devnull can't send the organization, and offers its page on the web", function () { +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)]); $screen = finishRefreshes(Native::visit('/log/organizations/nobody'))->assertNavTitle('nobody'); - nodeByRef($screen, 'error'); - expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'organization-header'))->toBeNull(); + $screen->assertSee("This organization's page didn't load here."); + expect(findNode($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['error', 'organization-header'], true)))->toBeNull(); $screen->tap('empty-link') ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/@nobody');