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
9 changes: 5 additions & 4 deletions app/Devnull/Data/BodyRun.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
/**
* 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. A mention of a GitHub
* organization has its handle in $username too, and $isOrganization set.
* draws with a swatch of it, or a hashtag, which searches commits for it. A
* link's text is the URL as written, and a color's or a hashtag's is 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 bool $isOrganization = false) {}
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 bool $isHashtag = false) {}

public function isMention(): bool
{
Expand Down
113 changes: 93 additions & 20 deletions app/Devnull/Data/CommitBody.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

/**
* Turns a commit's `body_segments` into blocks to draw, the way the web's
* CommitBody does: prose (with URLs and mentions linked, hex colors beside a
* swatch, and `/dev/null` as the wordmark) and inline code flow together in
* paragraphs, lines that start with `>` group into quotes, and fenced code
* blocks stand on their own.
* CommitBody does: prose (with URLs, mentions and hashtags linked, hex colors
* beside a swatch, and `/dev/null` as 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 and color offsets count code points into the whole body, but segments aren't
* Mention, color and hashtag 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
* prose the line break next to a block. So each segment is found in the body,
* moving forward, and mentions are placed by where their segment starts.
Expand Down Expand Up @@ -41,6 +41,14 @@ final class CommitBody
*/
private const string Color = '/(?:(?<!\S)|(?<=\n>))[#\x{FF03}]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\x{00AD}\x{2060}\x{200A}-\x{200D}\x{20E2}]|\z))/iu';

/**
* A hashtag: a `#` and a letter, then letters, marks, digits and
* underscores, not straight after a word character, `&`, `/`, `#`, `>` or
* `-`, so `a#b`, `&#123;` and a URL's fragment aren't one. The web's rule,
* from linkified-text.tsx.
*/
private const string Hashtag = '/(?<![\p{L}\p{M}\p{N}_&\/#>-])#\p{L}[\p{L}\p{M}\p{N}_]*/u';

/**
* A `/dev/null` that stands on its own, not part of a longer path, a word
* or a name. The web's rule, from withWordmarks() in linkified-text.tsx.
Expand Down Expand Up @@ -116,6 +124,7 @@ public static function blocks(string $body, ?array $segments, array $mentions =
$runs = [];
$cursor = 0;
$colors = self::colors($body);
$hashtags = self::hashtags($body);

foreach ($segments as $segment) {
switch ($segment['type'] ?? null) {
Expand All @@ -132,12 +141,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::withLinks($text.($cut ? self::Ellipsis : ''), null, [], []));
array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [], [], []));

break;
}

array_push($runs, ...self::withLinks($text, $start, $mentions, $colors));
array_push($runs, ...self::withLinks($text, $start, $mentions, $colors, $hashtags));
$cursor = $start + mb_strlen($text);

if ($cut) {
Expand Down Expand Up @@ -193,7 +202,7 @@ private static function paragraphs(array $runs): array
$quoted = false;

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

$lines = $prose
? array_map(fn (string $text): BodyRun => new BodyRun($text), preg_split('/(?<=\n)/', $run->text, flags: PREG_SPLIT_NO_EMPTY) ?: [])
Expand Down Expand Up @@ -266,15 +275,16 @@ private static function withoutTrailingBreak(array $runs): array
*
* @param list<Mention> $mentions
* @param list<array{0: int, 1: int, 2: string}> $colors
* @param list<array{0: int, 1: int}> $hashtags
* @return list<BodyRun>
*/
private static function withLinks(string $text, ?int $start, array $mentions, array $colors): array
private static function withLinks(string $text, ?int $start, array $mentions, array $colors, array $hashtags): array
{
$runs = [];
$position = 0;
$prose = fn (string $part, int $at): array => $start === null
? self::withWordmarks($part)
: self::withMentions($part, $start + $at, $mentions, $colors);
: self::withMentions($part, $start + $at, $mentions, $colors, $hashtags);

foreach (BodyLink::in($text) as [$offset, $length, $link]) {
if ($offset > $position) {
Expand All @@ -297,9 +307,10 @@ private static function withLinks(string $text, ?int $start, array $mentions, ar
*
* @param list<Mention> $mentions
* @param list<array{0: int, 1: int, 2: string}> $colors
* @param list<array{0: int, 1: int}> $hashtags
* @return list<BodyRun>
*/
private static function withMentions(string $text, int $start, array $mentions, array $colors): array
private static function withMentions(string $text, int $start, array $mentions, array $colors, array $hashtags): array
{
$runs = [];
$position = 0;
Expand All @@ -321,15 +332,15 @@ private static function withMentions(string $text, int $start, array $mentions,
}

if ($offset > $position) {
array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors));
array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors, $hashtags));
}

$runs[] = new BodyRun($handle, username: $mention->username, isOrganization: $mention->isOrganization);
$position = $offset + $mention->length;
}

if ($position < $length) {
array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors));
array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors, $hashtags));
}

return $runs;
Expand All @@ -340,9 +351,10 @@ private static function withMentions(string $text, int $start, array $mentions,
* in the whole body that fall inside it, as the web's withColors() does.
*
* @param list<array{0: int, 1: int, 2: string}> $colors
* @param list<array{0: int, 1: int}> $hashtags
* @return list<BodyRun>
*/
private static function withColors(string $text, int $start, array $colors): array
private static function withColors(string $text, int $start, array $colors, array $hashtags): array
{
$runs = [];
$position = 0;
Expand All @@ -355,11 +367,42 @@ private static function withColors(string $text, int $start, array $colors): arr
continue;
}

array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position)));
array_push($runs, ...self::withHashtags(mb_substr($text, $position, $offset - $position), $start + $position, $hashtags));
$runs[] = new BodyRun(mb_substr($text, $offset, $colorLength), color: $value);
$position = $offset + $colorLength;
}

array_push($runs, ...self::withHashtags(mb_substr($text, $position), $start + $position, $hashtags));

return $runs;
}

/**
* Splits prose that starts at $start in the body around the hashtags
* found in the whole body that fall inside it, as the web's withHashtags()
* does. One a color already took, like `#fff`, never falls inside.
*
* @param list<array{0: int, 1: int}> $hashtags
* @return list<BodyRun>
*/
private static function withHashtags(string $text, int $start, array $hashtags): array
{
$runs = [];
$position = 0;
$length = mb_strlen($text);

foreach ($hashtags as [$offset, $hashtagLength]) {
$offset -= $start;

if ($offset < $position || $offset + $hashtagLength > $length) {
continue;
}

array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position)));
$runs[] = new BodyRun(mb_substr($text, $offset, $hashtagLength), isHashtag: true);
$position = $offset + $hashtagLength;
}

array_push($runs, ...self::withWordmarks(mb_substr($text, $position)));

return $runs;
Expand All @@ -375,11 +418,7 @@ private static function withColors(string $text, int $start, array $colors): arr
*/
private static function colors(string $body): array
{
$prose = '';

foreach (self::parts($body) as $index => $part) {
$prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part));
}
$prose = self::prose($body);

preg_match_all(self::Color, "\n".$prose, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);

Expand All @@ -390,6 +429,40 @@ private static function colors(string $body): array
], $matches);
}

/**
* The hashtags in the body's prose, in order, as where each starts and how
* long it is in code points, `#` included. Code is blanked out first, so
* nothing in it counts.
*
* @return list<array{0: int, 1: int}>
*/
private static function hashtags(string $body): array
{
$prose = self::prose($body);

preg_match_all(self::Hashtag, $prose, $matches, PREG_OFFSET_CAPTURE);

return array_map(fn (array $match): array => [
mb_strlen(substr($prose, 0, $match[1])),
mb_strlen($match[0]),
], $matches[0]);
}

/**
* The body with its code blanked out to spaces of the same length, so
* what is found in the prose keeps its place in the body.
*/
private static function prose(string $body): string
{
$prose = '';

foreach (self::parts($body) as $index => $part) {
$prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part));
}

return $prose;
}

/**
* Splits prose around each standalone `/dev/null`, which becomes a
* wordmark run. Like the web, the prose between two mentions is looked at
Expand Down
17 changes: 17 additions & 0 deletions app/NativeComponents/Concerns/OpensHashtags.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\NativeComponents\Concerns;

/**
* A tapped hashtag searches commits for it, as the web's links to its search
* page with the hashtag, `#` included, as the term. Search opens in the tab it
* was tapped in (see OpensInTab, which the screen uses too) with the hashtag
* in its field and the commits for it loading.
*/
trait OpensHashtags
{
public function openHashtag(string $hashtag): void
{
$this->openInTab('/search', ['query' => $hashtag]);
}
}
1 change: 1 addition & 0 deletions app/NativeComponents/Concerns/ShowsCommits.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
trait ShowsCommits
{
use OpensCommitSheet;
use OpensHashtags;
use OpensStargazers;
use VotesOnPolls;
use WritesInBackground;
Expand Down
2 changes: 2 additions & 0 deletions app/NativeComponents/Conversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use App\Icons\Lucide;
use App\Models\PendingWrite;
use App\NativeComponents\Concerns\AttachesPhotos;
use App\NativeComponents\Concerns\OpensHashtags;
use App\NativeComponents\Concerns\OpensInTab;
use App\NativeComponents\Concerns\PlaysVoiceNotes;
use App\NativeComponents\Concerns\RecordsVoiceNotes;
Expand Down Expand Up @@ -103,6 +104,7 @@
class Conversation extends NativeComponent
{
use AttachesPhotos;
use OpensHashtags;
use OpensInTab;
use PlaysVoiceNotes;
use RecordsVoiceNotes;
Expand Down
2 changes: 2 additions & 0 deletions app/NativeComponents/ScheduledCommits.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Devnull\SiteLink;
use App\Devnull\Timezone;
use App\Devnull\Toasts;
use App\NativeComponents\Concerns\OpensHashtags;
use App\NativeComponents\Concerns\OpensInTab;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\TalksToDevnull;
Expand Down Expand Up @@ -36,6 +37,7 @@
*/
class ScheduledCommits extends NativeComponent
{
use OpensHashtags;
use OpensInTab;
use RefreshesInBackground;
use TalksToDevnull;
Expand Down
27 changes: 27 additions & 0 deletions app/NativeComponents/Search.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
* devnull won't search commits for fewer than three characters, so a shorter
* term only finds developers.
*
* A hashtag tapped in a commit or a message opens it with the hashtag as the
* term ($data['query']), searched straight away, as the web's hashtag links
* do, and the field isn't focused, so the keyboard leaves the results in view.
*
* Neither answer is kept (see ResponseCache): a search is about the moment.
*/
class Search extends NativeComponent
Expand Down Expand Up @@ -66,6 +70,11 @@ class Search extends NativeComponent

public string $query = '';

/**
* Whether the field takes focus when the screen opens: only for an empty one, as on the web.
*/
public bool $autofocus = true;

/** @var list<Connection> */
public array $people = [];

Expand All @@ -87,6 +96,16 @@ class Search extends NativeComponent
*/
private ?RowCache $rowCache = null;

public function mount(): void
{
$query = $this->data('query');

if (is_string($query) && trim($query) !== '') {
$this->autofocus = false;
$this->pick($query);
}
}

public function navTitle(): string
{
return 'Search';
Expand All @@ -106,6 +125,14 @@ public function pick(string $term): void
$this->search();
}

/**
* A hashtag in one of the results searches for it here, in place of what's in the field.
*/
public function openHashtag(string $hashtag): void
{
$this->pick($hashtag);
}

public function open(string $username): void
{
$person = $this->person($username);
Expand Down
5 changes: 5 additions & 0 deletions resources/views/native/partials/commit-body.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
A hex color has a square of that color before it, as the web's swatch, held to it by a narrow no-break space so
they wrap together.

A hashtag is medium weight in Commit Blue and searches commits for it on a tap, as the web's. In the composer's
preview ($static), as in the web's static preview, it stays plain text.

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.

Expand Down Expand Up @@ -52,6 +55,8 @@
<native:text :text="$run->link->shown" class="text-theme-primary" @press="openLink('{{ $commit->id }}', {{ $linkIndex++ }})" />
@elseif ($run->isColor())
<native:text><native:text text="■" class="text-[#{{ $run->color }}] dark:text-[#{{ $run->color }}]" />&#x202F;<native:text :text="$run->text" /></native:text>
@elseif ($run->isHashtag && ! ($static ?? false))
<native:text :text="$run->text" font="medium" class="font-medium text-theme-primary" @press="openHashtag('{{ $run->text }}')" />
@elseif ($run->isWordmark)
@php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1)))
<native:text font="semibold" class="font-semibold tracking-tight">@if ($afterSpace)&#x200B;@endif/&#x2060;dev<native:text text="/" class="text-outline text-theme-lane-0" />&#x2060;null</native:text>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/native/partials/composer-preview.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
@if (trim($sheetBody) === '' && $sheetImages === [] && $sheetPoll === null)
<native:text class="w-full text-[15px] leading-relaxed text-theme-ink-muted">Nothing to preview yet.</native:text>
@else
@include('native.partials.commit-body', ['commit' => $draftCommit, 'focused' => true])
@include('native.partials.commit-body', ['commit' => $draftCommit, 'focused' => true, 'static' => true])
@include('native.partials.commit-media', ['commit' => $draftCommit, 'withEmbeds' => false, 'scoreAction' => false])
@if ($draftCommit->poll)
<native:column ref="sheet-draft-poll" class="w-full mt-3 gap-2" a11y-label="Poll options">
Expand Down
Loading
Loading