diff --git a/app/Devnull/Data/BodyLink.php b/app/Devnull/Data/BodyLink.php new file mode 100644 index 00000000..9e8b7329 --- /dev/null +++ b/app/Devnull/Data/BodyLink.php @@ -0,0 +1,165 @@ + + */ + public static function in(string $prose): array + { + preg_match_all(self::pattern(), $prose, $matches, PREG_OFFSET_CAPTURE); + + $links = []; + + foreach ($matches[0] as [$candidate, $byte]) { + $written = self::withoutTrailingPunctuation($candidate); + $href = preg_match('/^(https?)(:\/\/.*)$/is', $written, $scheme) === 1 ? strtolower($scheme[1]).$scheme[2] : 'https://'.$written; + + if (str_ends_with($href, '://') || mb_strlen($href) > self::MaxLength) { + continue; + } + + $links[] = [mb_strlen(substr($prose, 0, $byte)), mb_strlen($written), self::to($written, $href)]; + } + + return $links; + } + + /** + * Whether it shows as a name and a short hash or number rather than as a URL. + */ + public function isNamed(): bool + { + return $this->prefix !== ''; + } + + private static function to(string $written, string $href): self + { + if (preg_match(self::CommitLink, $written, $commit) === 1) { + $id = strtolower($commit[2]); + + return new self($href, substr(str_replace('-', '', $id), -7), $commit[1].'/', $id); + } + + if (preg_match(self::GitHubLink, $href, $gitHub) === 1 && ! in_array(strtolower($gitHub[1]), self::GitHubPages, true)) { + return new self($href, $gitHub[2].(isset($gitHub[4]) ? '#'.$gitHub[4] : ''), $gitHub[1].'/'); + } + + return new self($href, self::shorten($written)); + } + + /** + * Like X: without its scheme or "www.", with the whole host, and at most 20 + * characters after it, the last an ellipsis when it's cut. A cut never splits + * a percent-encoded character. + */ + private static function shorten(string $url): string + { + $url = (string) preg_replace('/^(?:https?:\/\/)?(?:www\.(?=[^\/?#]*\.))?/i', '', $url); + $host = (string) preg_replace('/[\/?#].*$/su', '', $url); + $path = mb_substr($url, mb_strlen($host)); + + if (mb_strlen($path) <= self::MaxShortenedPathLength) { + return $url; + } + + $kept = (string) preg_replace('/%[0-9A-Fa-f]?$/', '', mb_substr($path, 0, self::MaxShortenedPathLength - 1)); + + return $host.$kept.'…'; + } + + /** + * A URL with a scheme, or a bare domain: ASCII labels ending in an allowed + * TLD, then maybe a path, query or fragment. A bare domain can't follow a + * letter, digit, "_", ".", "/", "@" or "-", its TLD is all lowercase or all + * uppercase, and it ends the word without a "(" or another label after it. + */ + private static function pattern(): string + { + $topLevelDomains = implode('|', [...self::TopLevelDomains, ...array_map(strtoupper(...), self::TopLevelDomains)]); + + return '/\b[Hh][Tt][Tt][Pp][Ss]?:\/\/[^\s<>]+' + .'|(?]*)?/u'; + } + + /** + * Strips punctuation that ends a sentence rather than the URL, keeping + * closing brackets that pair with one inside it. + */ + private static function withoutTrailingPunctuation(string $url): string + { + do { + $previous = $url; + $url = mb_rtrim($url, '.,;:!?\'"'); + + foreach (['(' => ')', '[' => ']'] as $open => $close) { + if (str_ends_with($url, $close) && mb_substr_count($url, $open) < mb_substr_count($url, $close)) { + $url = mb_substr($url, 0, -1); + } + } + } while ($url !== $previous); + + return $url; + } +} diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index 52006612..ed713af3 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -3,12 +3,13 @@ namespace App\Devnull\Data; /** - * A run of a paragraph: plain prose, a mention, inline code, or a standalone - * `/dev/null`, which draws as the wordmark. + * A run of a paragraph: plain prose, a mention, inline code, a link, or a + * standalone `/dev/null`, which draws as the wordmark. A link's text is the + * URL as written. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null) {} public function isMention(): bool { diff --git a/app/Devnull/Data/Commit.php b/app/Devnull/Data/Commit.php index ece96927..c4138a5f 100644 --- a/app/Devnull/Data/Commit.php +++ b/app/Devnull/Data/Commit.php @@ -227,6 +227,27 @@ public function plainText(): string )); } + /** + * The links in the body, in the order it draws them. A long commit's + * preview is cut at a space, so its links are the first of these. + * + * @return list + */ + public function links(): array + { + $links = []; + + foreach ($this->content as $block) { + foreach ($block->runs as $run) { + if ($run->link !== null) { + $links[] = $run->link; + } + } + } + + return $links; + } + public function isDeleted(): bool { return $this->deletedAt !== null; diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index d52e061a..c0788dda 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,8 +4,8 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does: prose (with mentions linked, and `/dev/null` as the - * wordmark) and inline code flow together in paragraphs, and fenced code + * CommitBody does: prose (with URLs and mentions linked, and `/dev/null` as + * the wordmark) and inline code flow together in paragraphs, and fenced code * blocks stand on their own. * * Mention offsets count code points into the whole body, but segments aren't @@ -60,12 +60,12 @@ public static function blocks(string $body, ?array $segments, array $mentions = $start = $text === '' ? false : mb_strpos($body, $text, $cursor); if ($start === false) { - array_push($runs, ...self::withWordmarks($text.($cut ? self::Ellipsis : ''))); + array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [])); break; } - array_push($runs, ...self::withMentions($text, $start, $mentions)); + array_push($runs, ...self::withLinks($text, $start, $mentions)); $cursor = $start + mb_strlen($text); if ($cut) { @@ -106,6 +106,38 @@ public static function blocks(string $body, ?array $segments, array $mentions = return $blocks; } + /** + * Splits prose that starts at $start in the body around the links inside + * it, then the prose between them around its mentions, as the web does. + * Prose that wasn't found in the body has no start, so no mentions. + * + * @param list $mentions + * @return list + */ + private static function withLinks(string $text, ?int $start, array $mentions): array + { + $runs = []; + $position = 0; + $prose = fn (string $part, int $at): array => $start === null + ? self::withWordmarks($part) + : self::withMentions($part, $start + $at, $mentions); + + foreach (BodyLink::in($text) as [$offset, $length, $link]) { + if ($offset > $position) { + array_push($runs, ...$prose(mb_substr($text, $position, $offset - $position), $position)); + } + + $runs[] = new BodyRun(mb_substr($text, $offset, $length), link: $link); + $position = $offset + $length; + } + + if ($position < mb_strlen($text)) { + array_push($runs, ...$prose(mb_substr($text, $position), $position)); + } + + return $runs; + } + /** * Splits prose that starts at $start in the body around the mentions inside it. * diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index e506842c..f1829df5 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -182,6 +182,23 @@ public function openImage(string $id, int $index = 0): void $this->openInBrowser(($this->shownCommit($id)?->images[$index] ?? null)?->url); } + /** + * Opens a link in a commit's body, by its place among the body's links: a + * commit on dev/null here in the app, anything else in the browser. + */ + public function openLink(string $id, int $index): void + { + $link = $this->shownCommit($id)?->links()[$index] ?? null; + + if ($link?->commit !== null) { + $this->openCommit($link->commit); + + return; + } + + $this->openInBrowser($link?->href); + } + /** * Share link: the web's copy-link button. The share sheet has Copy, and AirDrop and Messages besides. */ diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 144ef156..43b97648 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -11,6 +11,11 @@ slash, even after a space, so a zero-width space (U+200B) after a space lets the line break there as a browser would; without it, a few wordmarks in a row can't wrap at all. VoiceOver skips both and still reads "/dev/null". + A link is Commit Blue and opens on a tap, numbered by its place among the body's links. Like the web, a URL shows + shortened, and a dev/null commit or a GitHub repository, issue or pull request shows as a mono name with the owner + muted, held on one line by a word joiner after its slash. The web's dev/null and GitHub marks before those are icons, + which text runs can't hold, so they're left out. + A body too long to show whole in a list shows its start, as devnull cut it, and "Show more" in Commit Blue, which opens the rest where it is. The commit a page is about ($focused) is always whole. $muted sets it in muted ink, for the commits around the one a page is about. @@ -18,6 +23,7 @@ @php($size ??= 'text-[15px]') @php($ink = ($muted ?? false) ? 'text-theme-ink-muted' : 'text-theme-ink') @php($folded = ! ($focused ?? false) && $commit->isFolded() && ! in_array($commit->id, $unfolded ?? [], true)) +@php($linkIndex = 0) @foreach ($folded ? $commit->previewContent : $commit->content as $block) @if ($block->isCode()) @@ -32,6 +38,10 @@ @elseif ($run->isCode) +@elseif ($run->link?->isNamed()) +⁠ +@elseif ($run->link) + @elseif ($run->isWordmark) @php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1))) @if ($afterSpace)​@endif/⁠dev⁠null diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 3de9bb72..694d369e 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -223,6 +223,39 @@ ->and(strtolower($slash['dark_color']))->toBe(strtolower(config('native-ui.theme.dark.lane-0'))); }); +it('links URLs in Commit Blue, opening a dev/null commit here and anything else in the browser', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ + 'id' => 'c1', + 'body' => 'Read https://www.example.com/a-long-path-to-the-release-notes, then https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8 and github.com/laravel/framework/pull/42', + ])]))]); + + $screen = Native::visit('/log'); + $primary = strtolower(config('native-ui.theme.light.primary')); + $link = findNode($screen->tree(), fn (array $node): bool => ($node['props']['text'] ?? null) === 'example.com/a-long-path-to-the…'); + $repository = findNode($screen->tree(), fn (array $node): bool => ($node['children'][0]['props']['text'] ?? null) === 'laravel/'); + + expect(paragraphRuns($screen, 'Read'))->toBe([ + ['Read '], + ['example.com/a-long-path-to-the…', 'link'], + [', then '], + ['Hacksore/5149ca8', 'link'], + [' and '], + ['laravel/framework#42', 'link'], + ]) + ->and(strtolower($link['props']['color']))->toBe($primary) + ->and($repository['props']['font_name'])->toBe('mono') + ->and(strtolower($repository['props']['color']))->toBe($primary) + ->and(strtolower($repository['children'][0]['props']['color']))->toBe(strtolower(config('native-ui.theme.light.ink-muted'))); + + $screen->press("openLink('c1', 0)") + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://www.example.com/a-long-path-to-the-release-notes') + ->press("openLink('c1', 2)") + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/laravel/framework/pull/42') + ->assertNoNavigation() + ->press("openLink('c1', 1)") + ->assertNavigatedTo('/log/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8'); +}); + it('shows a link preview as a compact row and opens it in the browser', function () { Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ 'id' => 'c1', diff --git a/tests/Pest.php b/tests/Pest.php index e8ddf7b2..4a532fc7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -618,9 +618,10 @@ function pageJson(array $items, ?string $nextCursor = null, string $path = 'http /** * The runs of the first paragraph whose text contains $text, as [text, style]: - * 'mention' for a pressable run, 'code' for one on a wash, 'wordmark' for one - * drawn from runs of its own (read without its invisible line-break hints), or - * nothing for prose. + * 'mention' for a pressable @handle, 'link' for any other pressable run, + * 'code' for one on a wash, 'wordmark' for one drawn from runs of its own, or + * nothing for prose. A run drawn from runs of its own reads as their text, + * without the invisible line-break hints they hold. * * @return list */ @@ -645,11 +646,15 @@ function paragraphRuns(TestableComponent $screen, string $text): array expect($paragraph)->not->toBeNull("No paragraph with [{$text}] was rendered."); - return array_map(fn (array $run): array => match (true) { - isset($run['on_press']) => [$run['props']['text'], 'mention'], - isset($run['style']['bg_color']) => [$run['props']['text'], 'code'], - isset($run['children']) => [str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children']))), 'wordmark'], - default => [$run['props']['text']], + return array_map(function (array $run): array { + $text = $run['props']['text'] ?? str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children'] ?? []))); + + return match (true) { + isset($run['on_press']) => [$text, str_starts_with($text, '@') ? 'mention' : 'link'], + isset($run['style']['bg_color']) => [$text, 'code'], + isset($run['children']) => [$text, 'wordmark'], + default => [$text], + }; }, $paragraph['children']); } diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index fa56a800..135c3722 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -16,10 +16,29 @@ function runsOf(array $blocks): array $run->isMention() => [$run->text, '@'.$run->username], $run->isCode => [$run->text, 'code'], $run->isWordmark => [$run->text, 'wordmark'], + $run->link !== null => [$run->text, 'link'], default => [$run->text], }, $blocks[0]->runs); } +/** + * The first link in the first paragraph: where it goes, its muted prefix, what + * it shows, and the dev/null commit it opens in the app. + * + * @param list $blocks + * @return array{0: string, 1: string, 2: string, 3: string|null} + */ +function linkIn(array $blocks): array +{ + foreach ($blocks[0]->runs as $run) { + if ($run->link !== null) { + return [$run->link->href, $run->link->prefix, $run->link->shown, $run->link->commit]; + } + } + + throw new RuntimeException('There is no link.'); +} + it('links mentions by code point, so emoji before them do not shift them', function () { // "🚀" is one code point but two UTF-16 units and four bytes. $body = '🚀🚀 shipped with @ada and 👩‍💻 @grace-h!'; @@ -137,9 +156,15 @@ function runsOf(array $blocks): array }); it('leaves /dev/null inside longer paths, words, URLs and usernames as text', function () { - $body = '/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL https://example.com/dev/null @devnull'; + $body = '/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL https://example.com/dev/null example.com/?to=/dev/null @devnull'; - expect(runsOf(CommitBody::blocks($body, null)))->toBe([[$body]]); + expect(runsOf(CommitBody::blocks($body, null)))->toBe([ + ['/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL '], + ['https://example.com/dev/null', 'link'], + [' '], + ['example.com/?to=/dev/null', 'link'], + [' @devnull'], + ]); }); it('draws the wordmark beside mentions but not in inline code or code blocks', function () { @@ -171,3 +196,106 @@ function runsOf(array $blocks): array ['…'], ]); }); + +it('links a URL, shortened like the web, and still places the mentions around it', function () { + $url = 'https://www.example.com/a-long-path-to-the-release-notes'; + $body = "Thanks @ORIGINAL! See {$url} and @futuredev."; + + $blocks = CommitBody::blocks($body, null, [new Mention('original', 7, 9), new Mention('futuredev', mb_strpos($body, '@futuredev'), 10)]); + + expect(runsOf($blocks))->toBe([ + ['Thanks '], + ['@ORIGINAL', '@original'], + ['! See '], + [$url, 'link'], + [' and '], + ['@futuredev', '@futuredev'], + ['.'], + ])->and(linkIn($blocks))->toBe([$url, '', 'example.com/a-long-path-to-the…', null]); +}); + +it('leaves sentence punctuation out of a link, but keeps a bracket that pairs inside it', function (string $body, string $link, string $after) { + expect(runsOf(CommitBody::blocks($body, null)))->toBe([['Read '], [$link, 'link'], [$after]]); +})->with([ + 'a full stop' => ['Read https://laravel.com/docs.', 'https://laravel.com/docs', '.'], + 'a closing bracket' => ['Read https://en.wikipedia.org/wiki/Foo_(bar)).', 'https://en.wikipedia.org/wiki/Foo_(bar)', ').'], +]); + +it('links a bare domain to https', function () { + expect(linkIn(CommitBody::blocks('Built with nativephp.com', null)))->toBe(['https://nativephp.com', '', 'nativephp.com', null]) + ->and(linkIn(CommitBody::blocks('Read laravel.com/docs', null)))->toBe(['https://laravel.com/docs', '', 'laravel.com/docs', null]); +}); + +it('leaves code, files and emails that look like a domain as text', function (string $body) { + expect(runsOf(CommitBody::blocks($body, null)))->toBe([[$body]]); +})->with(['user.name', 'socket.io.js', 'deploy.sh', 'bug.It', 'ada@example.com', 'array.at(-1)', 'nativephp.community']); + +it('leaves URLs in inline code and code blocks as code', function () { + $body = "See `https://inline.example.com`:\n```text\nhttps://block.example.com\n```"; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => 'See '], + ['type' => 'code', 'code' => 'https://inline.example.com'], + ['type' => 'text', 'text' => ':'], + ['type' => 'block', 'language' => 'text', 'html' => 'https://block.example.com'], + ]); + + expect(runsOf($blocks))->toBe([['See '], ['https://inline.example.com', 'code'], [':']]) + ->and($blocks[1]->code)->toBe('https://block.example.com'); +}); + +it('shows a dev/null commit link as its author and short hash, to open in the app', function () { + $url = 'https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135FE5149CA8'; + + $blocks = CommitBody::blocks("same root cause as {$url}.", null); + + expect(runsOf($blocks))->toBe([['same root cause as '], [$url, 'link'], ['.']]) + ->and(linkIn($blocks))->toBe([$url, 'Hacksore/', '5149ca8', '01a14a9d-7971-73ea-8f35-135fe5149ca8']); +}); + +it('keeps other dev/null links as URLs', function (string $url) { + expect(linkIn(CommitBody::blocks($url, null)))->toMatchArray([0 => $url, 1 => '', 3 => null]); +})->with([ + 'https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8/photos/1', + 'https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8?tab=replies', + 'https://example.com/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8', + 'https://dev-null.sh/@Hacksore', +]); + +it('shows a GitHub repository, issue or pull request as GitHub writes it', function (string $written, string $href, string $prefix, string $shown) { + expect(linkIn(CommitBody::blocks("built on {$written}.", null)))->toBe([$href, $prefix, $shown, null]); +})->with([ + 'a repository' => ['https://github.com/laravel/framework', 'https://github.com/laravel/framework', 'laravel/', 'framework'], + 'an issue' => ['https://github.com/laravel/framework/issues/42', 'https://github.com/laravel/framework/issues/42', 'laravel/', 'framework#42'], + 'a pull request' => ['https://github.com/laravel/framework/pull/42', 'https://github.com/laravel/framework/pull/42', 'laravel/', 'framework#42'], + 'a bare domain' => ['github.com/laravel/framework', 'https://github.com/laravel/framework', 'laravel/', 'framework'], + 'www' => ['https://www.github.com/laravel/framework', 'https://www.github.com/laravel/framework', 'laravel/', 'framework'], + 'capitals' => ['HTTPS://GitHub.com/laravel/framework', 'https://GitHub.com/laravel/framework', 'laravel/', 'framework'], + 'a dot in the name' => ['http://github.com/nunomaduro/dev-null.sh', 'http://github.com/nunomaduro/dev-null.sh', 'nunomaduro/', 'dev-null.sh'], +]); + +it('keeps other GitHub links as URLs', function (string $url) { + expect(linkIn(CommitBody::blocks($url, null)))->toMatchArray([0 => $url, 1 => '']); +})->with([ + 'https://github.com/laravel', + 'https://github.com/laravel/framework/', + 'https://github.com/laravel/framework/blob/12.x/README.md', + 'https://github.com/laravel/framework/tree/12.x', + 'https://github.com/laravel/framework/releases', + 'https://github.com/laravel/framework/commit/4fa008f', + 'https://github.com/laravel/framework/pull/42/files', + 'https://github.com/laravel/framework/pull/42/', + 'https://github.com/laravel/framework/issues/0', + 'https://github.com/laravel/framework/issues/042', + 'https://github.com/laravel/framework/issues', + 'https://github.com/laravel/framework/issues/42#issuecomment-1', + 'https://github.com/laravel/framework/issues/42?page=2', + 'https://github.com/laravel/framework/discussions/42', + 'https://github.com/laravel/framework?tab=readme', + 'https://github.com/laravel/framework#readme', + 'https://github.com/features/actions', + 'https://github.com/orgs/laravel', + 'https://github.com/settings/profile', + 'https://gist.github.com/laravel/framework', + 'https://notgithub.com/laravel/framework', +]);