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
18 changes: 16 additions & 2 deletions app/Devnull/Data/BodyBlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
namespace App\Devnull\Data;

/**
* A block of a commit body: a paragraph of runs, or a fenced code block.
* A block of a commit body: a paragraph of runs, a quote (lines that start
* with `>`, the marker dropped), or a fenced code block.
*/
final readonly class BodyBlock
{
/**
* @param list<BodyRun> $runs
*/
private function __construct(public array $runs = [], public ?string $code = null, public ?string $language = null) {}
private function __construct(public array $runs = [], public ?string $code = null, public ?string $language = null, public bool $quoted = false) {}

/**
* @param list<BodyRun> $runs
Expand All @@ -20,6 +21,14 @@ public static function paragraph(array $runs): self
return new self(runs: $runs);
}

/**
* @param list<BodyRun> $runs
*/
public static function quote(array $runs): self
{
return new self(runs: $runs, quoted: true);
}

public static function code(string $code, ?string $language = null): self
{
return new self(code: $code, language: $language);
Expand All @@ -29,4 +38,9 @@ public function isCode(): bool
{
return $this->code !== null;
}

public function isQuote(): bool
{
return $this->quoted;
}
}
97 changes: 89 additions & 8 deletions app/Devnull/Data/CommitBody.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
/**
* Turns a commit's `body_segments` into blocks to draw, the way the web's
* 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.
* the wordmark) and inline code flow together in paragraphs, lines that start
* with `>` group into quotes, and fenced code blocks stand on their own.
*
* Mention offsets count code points into the whole body, but segments aren't
* exact slices of it: inline code loses its backticks, a block its fences, and
Expand Down Expand Up @@ -146,10 +146,8 @@ public static function blocks(string $body, ?array $segments, array $mentions =

break;
case 'block':
if ($runs !== []) {
$blocks[] = BodyBlock::paragraph($runs);
$runs = [];
}
array_push($blocks, ...self::paragraphs($runs));
$runs = [];

[$code, $cursor] = self::fencedCode($body, $cursor);
$language = $segment['language'] ?? null;
Expand All @@ -160,13 +158,96 @@ public static function blocks(string $body, ?array $segments, array $mentions =
}
}

if ($runs !== []) {
$blocks[] = BodyBlock::paragraph($runs);
array_push($blocks, ...self::paragraphs($runs));

return $blocks;
}

/**
* Splits a paragraph's runs into paragraphs and quotes, as the web's
* bodyBlocks() does: prose that starts a line with `>` quotes that line,
* minus the marker and one space after it, and the lines next to it that
* do the same join it. A `>` inside code, or anywhere but a line's start,
* stays as written. The line break before each change is dropped, since
* the blocks are drawn one under the other.
*
* @param list<BodyRun> $runs
* @return list<BodyBlock>
*/
private static function paragraphs(array $runs): array
{
/** @var list<array{quoted: bool, runs: list<BodyRun>}> $groups */
$groups = [];
$atLineStart = true;
$quoted = false;

foreach ($runs as $run) {
$prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null;

$lines = $prose
? array_map(fn (string $text): BodyRun => new BodyRun($text), preg_split('/(?<=\n)/', $run->text, flags: PREG_SPLIT_NO_EMPTY) ?: [])
: [$run];

foreach ($lines as $line) {
if ($atLineStart) {
$quoted = $prose && str_starts_with($line->text, '>');
}

if ($atLineStart && $quoted) {
$line = new BodyRun((string) preg_replace('/^> ?/', '', $line->text));
}

$last = array_key_last($groups);

if ($last === null || $groups[$last]['quoted'] !== $quoted) {
if ($last !== null) {
$groups[$last]['runs'] = self::withoutTrailingBreak($groups[$last]['runs']);
}

$groups[] = ['quoted' => $quoted, 'runs' => []];
$last = array_key_last($groups);
}

if ($line->text !== '') {
$groups[$last]['runs'][] = $line;
}

$atLineStart = $prose && str_ends_with($line->text, "\n");
}
}

$blocks = [];

foreach ($groups as $group) {
if ($group['runs'] !== [] || $group['quoted']) {
$blocks[] = $group['quoted'] ? BodyBlock::quote($group['runs']) : BodyBlock::paragraph($group['runs']);
}
}

return $blocks;
}

/**
* @param list<BodyRun> $runs
* @return list<BodyRun>
*/
private static function withoutTrailingBreak(array $runs): array
{
$last = array_pop($runs);

if ($last === null) {
return $runs;
}

$text = (string) preg_replace('/\r?\n$/', '', $last->text);

if ($text !== '') {
$runs[] = $last->text === $text ? $last : new BodyRun($text);
}

return $runs;
}

/**
* 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.
Expand Down
9 changes: 9 additions & 0 deletions resources/views/native/partials/commit-body.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
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 quote, lines that start with ">", sits behind an amber rule on the left like the web's blockquote, its
runs drawn as a paragraph's.

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,
Expand All @@ -32,6 +35,9 @@
<native:text :text="$block->code" font="mono" class="w-full px-3 py-2 text-[13px] leading-snug {{ $ink }} whitespace-pre-wrap select-text" />
</native:column>
@else
@if ($block->isQuote())
<native:column class="w-full my-1 pl-3 border-l-2 border-theme-lane-5">
@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 }}')" />
Expand All @@ -49,6 +55,9 @@
@endif
@endforeach
</native:text>
@if ($block->isQuote())
</native:column>
@endif
@endif
@endforeach
@if ($folded)
Expand Down
11 changes: 9 additions & 2 deletions resources/views/native/partials/message-body.blade.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
{{--
A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable
mentions and inline code, and fenced code as mono text in a box. $ink is the bubble's text colour, so a body
on your lane reads in that lane's label colour.
mentions and inline code, quotes behind a rule on the left, and fenced code as mono text in a box. $ink is the
bubble's text colour, so a body on your lane reads in that lane's label colour, and $quoteRule the quote's rule:
amber, or the label colour on your lane, as the web's pale rule in your own bubbles.
--}}
@foreach ($message->content as $block)
@if ($block->isCode())
<native:column class="w-full mt-1 mb-1 rounded-md border border-theme-hairline bg-theme-wash">
<native:text :text="$block->code" font="mono" class="w-full px-3 py-2 text-[13px] leading-snug text-theme-ink whitespace-pre-wrap select-text" />
</native:column>
@else
@if ($block->isQuote())
<native:column class="my-1 pl-3 border-l-2 {{ $quoteRule }}">
@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 }}')" />
Expand All @@ -19,5 +23,8 @@
@endif
@endforeach
</native:text>
@if ($block->isQuote())
</native:column>
@endif
@endif
@endforeach
4 changes: 2 additions & 2 deletions resources/views/native/partials/message-item.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@
@endif
@if ($message->isPending())
<native:column ref="bubble-{{ $key }}" class="flex-shrink px-3 py-2 rounded-2xl {{ $message->status === 'rejected' ? 'border border-theme-destructive bg-theme-surface' : 'bg-theme-'.$myColour.'/60' }}">
@include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour])
@include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour, 'quoteRule' => $message->status === 'rejected' ? 'border-theme-lane-5' : 'border-theme-on-'.$myColour])
</native:column>
@else
<native:column ref="bubble-{{ $key }}" class="flex-shrink px-3 py-2 rounded-2xl {{ $mine ? 'bg-theme-'.$myColour : 'bg-theme-wash' }}" @press="toggleActions({{ $message->id }})" @doubleTap="heart({{ $message->id }})" a11y-hint="Tap for React and Reply">
@include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary'])
@include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary', 'quoteRule' => $mine ? 'border-theme-on-'.$myColour : 'border-theme-lane-5'])
</native:column>
@endif
@if (! $mine)
Expand Down
23 changes: 23 additions & 0 deletions tests/Feature/Screens/CommitDetailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,29 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []):
$screen->press("openProfile('ada')")->assertNavigatedTo('/log/users/ada');
});

it('draws quote lines behind an amber rule, without their markers', function () {
$body = "> Ship it on Friday?\n> @ada said so\nNot on my watch";

fakeThread([
'body' => $body,
'body_segments' => [['type' => 'text', 'text' => $body]],
'mentions' => [['username' => 'ada', 'offset' => 23, 'length' => 4]],
]);

$screen = Native::visit('/log/commits/c2')
->assertSee('Not on my watch')
->assertDontSee('> Ship it');

$quote = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'column'
&& (float) ($node['props']['border_l'] ?? 0) === 2.0);

expect($quote)->not->toBeNull()
->and($quote['props'])->toMatchArray(['border_t' => 0, 'border_r' => 0, 'border_b' => 0])
->and(strtolower($quote['style']['border_color']))->toBe(strtolower((string) theme('lane-5')))
->and(paragraphRuns($screen, 'Ship it'))->toBe([["Ship it on Friday?\n"], ['@ada', 'mention'], [' said so']])
->and(paragraphRuns($screen, 'Not on my watch'))->toBe([['Not on my watch']]);
});

it('shows the focused commit with its media and stars', function () {
fakeThread([
'author' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace']),
Expand Down
17 changes: 17 additions & 0 deletions tests/Feature/Screens/ConversationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus =
->and(nodeByRef($screen, 'thread')['props']['scroll_anchor'] ?? null)->toBe('bottom');
});

it('draws quotes in messages behind a rule, amber in theirs and in the label colour in yours', function () {
serveRemoteThread(threadJson([
remoteMessageJson(['id' => 1, 'body' => "> did you see the PR?\nnot yet"]),
remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => "> not yet\nlooking now"]),
]));

$screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))
->assertSee('did you see the PR?')
->assertDontSee('> did you see the PR?');

$rule = fn (string $ref): ?string => findNode(nodeByRef($screen, $ref), fn (array $node): bool => (float) ($node['props']['border_l'] ?? 0) === 2.0)['style']['border_color'] ?? null;

expect(strtolower((string) $rule('bubble-1')))->toBe(strtolower((string) theme('lane-5')))
->and(strtolower((string) $rule('bubble-2')))->not->toBe(strtolower((string) theme('lane-5')))
->and($rule('bubble-2'))->not->toBeNull();
});

it('marks HEAD where you left off, and pulls what is showing', function () {
serveRemoteThread(threadJson([
remoteMessageJson(['id' => 1, 'body' => 'first']),
Expand Down
73 changes: 73 additions & 0 deletions tests/Unit/Devnull/CommitBodyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,76 @@ function linkIn(array $blocks): array
'https://gist.github.com/laravel/framework',
'https://notgithub.com/laravel/framework',
]);

/**
* Each block as its kind and its text, runs joined.
*
* @param list<BodyBlock> $blocks
* @return list<array{0: string, 1: string}>
*/
function quotesOf(array $blocks): array
{
return array_map(fn (BodyBlock $block): array => [
match (true) {
$block->isCode() => 'code',
$block->isQuote() => 'quote',
default => 'paragraph',
},
$block->isCode() ? (string) $block->code : implode('', array_map(fn (BodyRun $run): string => $run->text, $block->runs)),
], $blocks);
}

it('groups lines that start with > into quotes, as the web does', function () {
$body = "> First `code`\n> @ada https://example.com\nNormal > text\n>Last";

$blocks = CommitBody::blocks($body, [
['type' => 'text', 'text' => '> First '],
['type' => 'code', 'code' => 'code'],
['type' => 'text', 'text' => "\n> @ada https://example.com\nNormal > text\n>Last"],
], [new Mention('ada', 17, 4)]);

expect(quotesOf($blocks))->toBe([
['quote', "First code\n@ada https://example.com"],
['paragraph', 'Normal > text'],
['quote', 'Last'],
])->and(array_map(fn (BodyRun $run): array => [$run->text, $run->isCode, $run->username, $run->link?->href], $blocks[0]->runs))->toBe([
['First ', false, null, null],
['code', true, null, null],
["\n", false, null, null],
['@ada', false, 'ada', null],
[' ', false, null, null],
['https://example.com', false, null, 'https://example.com'],
]);
});

it('leaves a > that is inside code or not at the start of a line as written', function () {
$body = "`code`> literal\n```text\n> fenced\n```\n> quoted\n>\n> `> inline`\n\nAfter";

$blocks = CommitBody::blocks($body, [
['type' => 'code', 'code' => 'code'],
['type' => 'text', 'text' => '> literal'],
['type' => 'block', 'language' => 'text'],
['type' => 'text', 'text' => "> quoted\n>\n> "],
['type' => 'code', 'code' => '> inline'],
['type' => 'text', 'text' => "\n\nAfter"],
]);

expect(quotesOf($blocks))->toBe([
['paragraph', 'code> literal'],
['code', '> fenced'],
['quote', "quoted\n\n> inline"],
['paragraph', "\nAfter"],
]);
});

it('keeps Windows line breaks inside a quote and drops the one before the next block', function () {
expect(quotesOf(CommitBody::blocks("Before\r\n> one\r\n> two\r\nAfter", null)))->toBe([
['paragraph', 'Before'],
['quote', "one\r\ntwo"],
['paragraph', 'After'],
]);
});

it('quotes a line that is only the marker', function () {
expect(quotesOf(CommitBody::blocks('>', null)))->toBe([['quote', '']]);
});
Loading