Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/Devnull/Data/BodyRun.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 1 addition & 1 deletion app/Devnull/Data/CommitBody.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 5 additions & 4 deletions app/Devnull/Data/Mention.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
}
}
1 change: 1 addition & 0 deletions app/Devnull/ResponseCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class ResponseCache
'#^commits/[^/]+$#',
'#^commits/[^/]+/(replies|related)$#',
'#^users/[^/]+$#',
'#^organizations/[^/]+$#',
'#^users/[^/]+/commits$#',
'#^starred$#',
'#^users/[^/]+/(followers|following)$#',
Expand Down
8 changes: 8 additions & 0 deletions app/NativeComponents/Concerns/ShowsCommits.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
8 changes: 8 additions & 0 deletions app/NativeComponents/Conversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
104 changes: 104 additions & 0 deletions app/NativeComponents/Organization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace App\NativeComponents;

use App\Devnull\Data\User;
use App\Devnull\SiteLink;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\TalksToDevnull;
use Illuminate\View\View;
use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Facades\Browser;

/**
* 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.
*
* 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.
*/
class Organization extends NativeComponent
{
use RefreshesInBackground;
use TalksToDevnull;

public ?User $organization = null;

public function mount(): void
{
$this->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<string, mixed> $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');
}
}
58 changes: 58 additions & 0 deletions resources/views/native/organization.blade.php
Original file line number Diff line number Diff line change
@@ -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. devnull's error
isn't printed: the page says itself when it didn't load, and offers the page on the web.
--}}
<native:scroll-view class="w-full h-full bg-theme-surface">
<native:column class="w-full">
@include('native.partials.refresh-failed')

@if ($organization)
<native:column ref="organization-header" class="w-full px-4 pt-6 pb-4 gap-4">
<native:stack :width="80" :height="80" class="rounded-lg bg-theme-wash" center>
<native:text font="semibold" class="text-theme-ink-muted" :font-size="32">{{ mb_strtoupper(mb_substr($organization->name, 0, 1)) }}</native:text>
@if ($organization->avatarUrl)
<native:image :src="\App\Devnull\ImageCache::src($organization->avatarUrl)" :width="80" :height="80" :fit="2" class="rounded-lg" />
@endif
</native:stack>

<native:column class="w-full gap-1">
<native:text font="semibold" class="text-2xl tracking-tight text-theme-ink">{{ $organization->name }}</native:text>
<native:row class="w-full gap-2 items-center">
<native:text font="mono" class="text-[13px] text-theme-ink-muted flex-shrink" :max-lines="1">{{ '@'.$organization->username }}</native:text>
<native:column ref="organization-badge" class="px-2 py-[2] rounded bg-theme-wash flex-shrink-0">
<native:text class="text-xs text-theme-ink-muted">Organization</native:text>
</native:column>
</native:row>
</native:column>

@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

<native:column ref="organization-facts" class="w-full gap-[6]">
@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'])
</native:column>
</native:column>
<native:column class="w-full h-[1] bg-theme-hairline" />

@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
</native:column>
</native:scroll-view>
2 changes: 1 addition & 1 deletion resources/views/native/partials/commit-body.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
@endif
<native:text class="w-full {{ $size }} leading-relaxed {{ $ink }} whitespace-pre-wrap">@foreach ($block->runs as $run)
@if ($run->isMention())
<native:text :text="$run->text" font="semibold" class="font-semibold text-theme-primary" @press="openProfile('{{ $run->username }}')" />
<native:text :text="$run->text" font="semibold" class="font-semibold text-theme-primary" @press="{{ $run->isOrganization ? 'openOrganization' : 'openProfile' }}('{{ $run->username }}')" />
@elseif ($run->isCode)
<native:text :text="$run->text" font="mono" class="text-[13px] bg-theme-wash" />
@elseif ($run->link?->isNamed())
Expand Down
2 changes: 1 addition & 1 deletion resources/views/native/partials/message-body.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
@endif
<native:text class="text-[15px] leading-snug {{ $ink }} whitespace-pre-wrap">@foreach ($block->runs as $run)
@if ($run->isMention())
<native:text :text="$run->text" font="semibold" class="font-semibold {{ $mentionInk }}" @press="openProfile('{{ $run->username }}')" />
<native:text :text="$run->text" font="semibold" class="font-semibold {{ $mentionInk }}" @press="{{ $run->isOrganization ? 'openOrganization' : 'openProfile' }}('{{ $run->username }}')" />
@elseif ($run->isCode)
<native:text :text="$run->text" font="mono" class="text-[13px]" />
@elseif ($run->isColor())
Expand Down
2 changes: 2 additions & 0 deletions routes/mobile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions tests/Feature/Screens/ConversationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']]),
Expand Down
23 changes: 23 additions & 0 deletions tests/Feature/Screens/LogTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
48 changes: 48 additions & 0 deletions tests/Feature/Screens/OrganizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

use Illuminate\Support\Facades\Http;
use Native\Mobile\Testing\Native;

beforeEach(function () {
config(['devnull.url' => '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 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');

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