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
23 changes: 21 additions & 2 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@
*/
public const string IssuesPath = 'issues';

/**
* How many replies the app asks for at a time. devnull sends 50 a page
* unless it's asked for fewer, and the app never asks for more than this.
*/
public const int RepliesPerPage = 5;

public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {}

/**
Expand Down Expand Up @@ -530,7 +536,7 @@ public static function notificationsQuery(string $type, ?string $cursor = null):
*/
public function signedGet(string $path, array $query = []): SignedRequest
{
$target = $this->target($path, $query);
$target = $this->target($path, self::paged($path, $query));

return new SignedRequest($this->origin().$target, $this->sign('GET', $target, ''));
}
Expand Down Expand Up @@ -575,7 +581,7 @@ public static function sendSigned(SignedRequest $request): Response
*/
private function get(string $path, array $query = []): array
{
$json = $this->json($this->send('GET', $path, $query));
$json = $this->json($this->send('GET', $path, self::paged($path, $query)));
$this->cache->put($path, $query, $json);
ImageCache::prefetch($json);

Expand Down Expand Up @@ -690,6 +696,19 @@ private function sign(string $method, string $target, string $body, string $name
->headers($method, $target, $body, CarbonImmutable::now()->getTimestamp(), RequestSigner::nonce(), $namespace);
}

/**
* A GET's query with the page size the app asks for: a page of replies
* holds RepliesPerPage. The size isn't part of what the answer's kept
* under, as every page of replies has it.
*
* @param array<string, string|null> $query
* @return array<string, string|null>
*/
private static function paged(string $path, array $query): array
{
return preg_match('#^commits/[^/]+/replies$#', $path) === 1 ? [...$query, 'limit' => (string) self::RepliesPerPage] : $query;
}

/**
* The request target: the path as the server sees it, plus the query string.
*
Expand Down
91 changes: 73 additions & 18 deletions app/NativeComponents/CommitDetail.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\NativeComponents;

use App\Devnull\Account;
use App\Devnull\Client;
use App\Devnull\Data\Commit;
use App\Devnull\Data\CommitThread;
use App\Devnull\Data\Page;
Expand All @@ -29,8 +30,12 @@
* parts below, with its replies between, as on the web. Deleted ancestors,
* parts and replies show as tombstones.
*
* The commit opens at the top, with what it replies to behind "↑ N earlier
* commits" and its replies behind "› N replies", each opened in place.
* The commit opens near the top, under the commit it replies to, with the
* ones before that behind "↑ N earlier commits" and its replies behind
* "› N replies", each opened in place. Opened from a "› N replies" button
* ($data['replies']), its replies already show. They come five at a time
* (see Client::RepliesPerPage), and "Show more" under the last asks for the
* next five, as it does under a deleted reply's replies opened in place.
*
* It opens on the commit as it was last time, or opened from a row, on what
* the row knew of it, then asks devnull for the rest behind it and fills it in.
Expand Down Expand Up @@ -91,6 +96,13 @@ class CommitDetail extends NativeComponent
*/
public array $tombstoneReplies = [];

/**
* Where the next page of each deleted reply's replies starts, by the deleted reply's id, or null when they're all shown.
*
* @var array<string, string|null>
*/
public array $tombstoneCursors = [];

public string $reply = '';

/**
Expand All @@ -99,7 +111,7 @@ class CommitDetail extends NativeComponent
public bool $replyRestored = false;

/**
* Whether the ancestors show above the commit, rather than "↑ N earlier commits".
* Whether the ancestors before the commit's parent show above it, rather than "↑ N earlier commits".
*/
public bool $earlierShown = false;

Expand Down Expand Up @@ -136,6 +148,7 @@ class CommitDetail extends NativeComponent
public function mount(): void
{
$this->focusReply = $this->data('reply', false) === true;
$this->repliesShown = $this->data('replies', false) === true;
$this->reply = app(Stash::class)->get($this->me()?->id, $this->replyStashName());
$this->replyRestored = $this->reply !== '';
$this->showKept();
Expand Down Expand Up @@ -193,7 +206,11 @@ protected function revalidate(): void

$this->refresh('commits/'.rawurlencode($id).'/replies', [], function (array $json) use ($id): void {
if (array_key_exists($id, $this->tombstoneReplies)) {
$this->tombstoneReplies[$id] = Page::fromArray($json, Commit::fromArray(...))->items;
[$this->tombstoneReplies[$id], $this->tombstoneCursors[$id]] = self::withFirstPage(
$this->tombstoneReplies[$id],
$this->tombstoneCursors[$id] ?? null,
Page::fromArray($json, Commit::fromArray(...)),
);
}
});
}
Expand All @@ -213,8 +230,9 @@ public function showEarlier(): void
}

/**
* Tapping into the reply bar brings the commit being answered to the top:
* the earlier commits fold away again and the page starts over from the top.
* Tapping into the reply bar brings the commit being answered back up, under
* its parent: the earlier commits fold away again and the page starts over
* from the top.
*/
public function replyTapped(): void
{
Expand All @@ -236,14 +254,33 @@ public function toggleReplies(): void
public function toggleTombstone(string $id): void
{
if (array_key_exists($id, $this->tombstoneReplies)) {
unset($this->tombstoneReplies[$id]);
unset($this->tombstoneReplies[$id], $this->tombstoneCursors[$id]);

return;
}

$this->loadTombstoneReplies($id);
}

/**
* "Show more" under a deleted reply's replies: its next five, in place.
*/
public function loadMoreTombstoneReplies(string $id): void
{
$cursor = $this->tombstoneCursors[$id] ?? null;

if ($cursor === null) {
return;
}

$page = $this->attempt(fn ($devnull) => $devnull->replies($id, $cursor));

if ($page !== null && array_key_exists($id, $this->tombstoneReplies)) {
$this->tombstoneReplies[$id] = [...$this->tombstoneReplies[$id], ...$page->items];
$this->tombstoneCursors[$id] = $page->nextCursor;
}
}

public function loadMoreReplies(): void
{
if ($this->repliesCursor === null) {
Expand Down Expand Up @@ -503,19 +540,35 @@ private function takeThread(array $json): void
*/
private function takeReplies(array $json): void
{
$page = Page::fromArray($json, Commit::fromArray(...));
$fresh = array_flip(array_map(fn (Commit $reply): string => $reply->id, $page->items));

$later = $this->repliesCursor !== null
? array_values(array_filter(array_slice($this->replies, count($page->items)), fn (Commit $reply): bool => ! isset($fresh[$reply->id])))
: [];

$this->replies = [...$page->items, ...$later];
$this->repliesCursor = $later === [] ? $page->nextCursor : $this->repliesCursor;
[$this->replies, $this->repliesCursor] = self::withFirstPage($this->replies, $this->repliesCursor, Page::fromArray($json, Commit::fromArray(...)));
$this->repliesLoaded = true;
$this->keepStagedReplies();
}

/**
* A fresh first page over the replies showing, and where the next page
* starts. Pages loaded after the first stay, past what it holds: there's
* a page after it still to load, or more show than a page holds. Staged
* replies are left for keepStagedReplies() to put back.
*
* @param list<Commit> $shown
* @param Page<Commit> $page
* @return array{0: list<Commit>, 1: string|null}
*/
private static function withFirstPage(array $shown, ?string $cursor, Page $page): array
{
$shown = array_values(array_filter($shown, fn (Commit $reply): bool => ! $reply->isStaged()));

if ($cursor === null && count($shown) <= Client::RepliesPerPage) {
return [$page->items, $page->nextCursor];
}

$fresh = array_flip(array_map(fn (Commit $reply): string => $reply->id, $page->items));
$later = array_values(array_filter(array_slice($shown, count($page->items)), fn (Commit $reply): bool => ! isset($fresh[$reply->id])));

return [[...$page->items, ...$later], $later === [] ? $page->nextCursor : $cursor];
}

private function replyStashName(): string
{
return 'reply-'.$this->id;
Expand All @@ -532,6 +585,7 @@ private function loadTombstoneReplies(string $id): void

if ($page !== null) {
$this->tombstoneReplies[$id] = $page->items;
$this->tombstoneCursors[$id] = $page->nextCursor;
}
}

Expand All @@ -542,7 +596,8 @@ public function isOwn(): bool

public function render(): View
{
$earlier = $this->earlierShown ? [] : $this->ancestors;
$earlier = $this->earlierShown ? [] : array_slice($this->ancestors, 0, -1);
$above = $this->earlierShown ? $this->ancestors : array_slice($this->ancestors, -1);
$viewer = $this->me();
$this->replyRows ??= new RowCache;
$this->replyRows->startFrame();
Expand All @@ -554,7 +609,7 @@ public function render(): View
'stashed' => app(Stash::class)->names($viewer?->id),
'pasteis' => app(Account::class)->pasteis(),
'rows' => $this->commit === null ? [] : Timeline::thread(
[...($this->earlierShown ? $this->ancestors : []), $this->commit, ...$this->series],
[...$above, $this->commit, ...$this->series],
laneAbove: $earlier === [] ? null : end($earlier)->lane(),
),
'earlierCount' => $this->commit === null ? 0 : count($earlier),
Expand Down
36 changes: 29 additions & 7 deletions app/NativeComponents/Concerns/ExpandsThreads.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
* the web's thread toggle does, and "Hide replies" closes them again. One
* level deep: a reply's own replies open its page.
*
* The app asks for replies oldest first, five at a time (see
* Client::RepliesPerPage). A thread opened in place shows its first five,
* and "Show more" under them opens the rest on the commit's page, with its
* replies showing.
*
* Under a commit devnull sends with its newest reply, "Show more" opens the
* rest in place, newest first as on the web. The API only pages replies
* oldest first, 50 at a time, so they're turned round here, and a thread
* longer than a page opens the commit instead.
* rest in place, newest first as on the web, so they're turned round here,
* and a thread longer than a page opens the commit's page instead.
*
* A thread opened before opens at once from the cache, and refreshes behind.
*/
Expand All @@ -33,10 +37,17 @@ trait ExpandsThreads
*/
public array $newestFirst = [];

/**
* Threads opened in place with more replies than they show.
*
* @var list<string>
*/
public array $threadsWithMore = [];

public function toggleReplies(string $id): void
{
if (array_key_exists($id, $this->threads)) {
unset($this->threads[$id]);
$this->closeThread($id);

return;
}
Expand Down Expand Up @@ -113,29 +124,40 @@ private function openThread(string $id, Page $page): void
{
// A thread whose last reply went closes, so its commit gets its hairline back.
if ($page->items === []) {
unset($this->threads[$id]);
$this->closeThread($id);

return;
}

if (! in_array($id, $this->newestFirst, true)) {
$this->threads[$id] = $page->items;
$this->threadsWithMore = array_values(array_diff($this->threadsWithMore, [$id]));

if ($page->hasMore()) {
$this->threadsWithMore[] = $id;
}

return;
}

// More than a page: the newest replies aren't on it, so the commit's page shows them.
if ($page->hasMore()) {
unset($this->threads[$id]);
$this->closeThread($id);
$this->newestFirst = array_values(array_diff($this->newestFirst, [$id]));
$this->openCommit($id);
$this->openReplies($id);

return;
}

$this->threads[$id] = array_reverse($page->items);
}

private function closeThread(string $id): void
{
unset($this->threads[$id]);
$this->threadsWithMore = array_values(array_diff($this->threadsWithMore, [$id]));
}

/**
* @return list<Commit>
*/
Expand Down
14 changes: 14 additions & 0 deletions app/NativeComponents/Concerns/ShowsCommits.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ public function openCommit(string $id): void
$this->openInTab('/commits/'.$id, $commit === null ? [] : ['commit' => $commit]);
}

/**
* "› N replies" where they can't open in place: the commit's page, with its replies already showing.
*/
public function openReplies(string $id): void
{
$commit = $this->shownCommit($id);

if ($commit?->isStaged() === true) {
return;
}

$this->openInTab('/commits/'.$id, ['replies' => true, ...($commit === null ? [] : ['commit' => $commit])]);
}

/**
* Opens a profile with what a row already knows of its author.
*/
Expand Down
29 changes: 18 additions & 11 deletions resources/views/native/commit-detail.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
@endif

{{--
The commit is the main thing, at the top: larger, in full ink, with its git date and its replies behind
a toggle in its action row. What it replies to waits behind "↑ N earlier commits" above it, since EDGE
can't open the page scrolled down to it. Opened, the ancestors, the commit and the rest of its series
share one rail, so someone replying to themselves shows their avatar once. The replies hang under it
on the thread's dashed lines, with the rail ending in a dot above them. A part of a series with more
parts after it has its replies right under it, beside the rail running on, and the later parts follow.
Everything around the commit is muted, and still opens its own page. A deleted reply's own replies
open in place, one level in, since a deleted commit has no page.
The commit is the main thing, near the top: larger, in full ink, with its git date and its replies behind
a toggle in its action row. The commit it replies to shows above it, and what that replies to waits
behind "↑ N earlier commits", since EDGE can't open the page scrolled down to the commit. Opened, the
ancestors, the commit and the rest of its series share one rail, so someone replying to themselves shows
their avatar once. The replies hang under it on the thread's dashed lines, with the rail ending in a dot
above them, five at a time with "Show more" under the last. A part of a series with more parts after it
has its replies right under it, beside the rail running on, and the later parts follow. Everything
around the commit is muted, and still opens its own page. A deleted reply's own replies open in place,
one level in, five at a time too, since a deleted commit has no page.
--}}
@if ($earlierCount > 0)
@include('native.partials.earlier-commits', ['count' => $earlierCount, 'lane' => $earlierLane])
Expand Down Expand Up @@ -48,6 +49,7 @@
@php($railLane = $loop->last ? null : $commit->lane())
@foreach ($replies as $index => $threadReply)
@php($nested = $tombstoneReplies[$threadReply->id] ?? [])
@php($nestedMore = ($tombstoneCursors[$threadReply->id] ?? null) !== null)
@php($hasNext = ! $loop->last || $repliesCursor !== null)
@php($replyRow = [
'reply' => $threadReply,
Expand All @@ -68,13 +70,18 @@
'reply' => $nestedReply,
'lane' => \App\Devnull\Lane::Rail,
'previousLane' => $nestedIndex === 0 ? null : $nested[$nestedIndex - 1]->lane(),
'hasNext' => ! $loop->last,
'hasNext' => ! $loop->last || $nestedMore,
'railLane' => $railLane,
'indent' => 32,
'spine' => $hasNext ? $threadReply->lane() : null,
'muted' => true,
'thread' => $commit,
'more' => $loop->last && $loop->parent->last && $repliesCursor !== null ? 'loadMoreReplies' : null,
'thread' => $nestedMore ? $threadReply : $commit,
'more' => match (true) {
! $loop->last => null,
$nestedMore => "loadMoreTombstoneReplies('{$threadReply->id}')",
$loop->parent->last && $repliesCursor !== null => 'loadMoreReplies',
default => null,
},
'last' => $loop->last && $loop->parent->last,
])
@php($rowScope = array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))
Expand Down
Loading
Loading