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
21 changes: 18 additions & 3 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,22 @@ public function unstar(string $id): Commit
*/
public function bookmark(string $id): Commit
{
return $this->react('PUT', 'commits/'.rawurlencode($id).'/bookmark');
return $this->react('PUT', 'commits/'.rawurlencode($id).'/bookmark', saving: true);
}

public function unbookmark(string $id): Commit
{
return $this->react('DELETE', 'commits/'.rawurlencode($id).'/bookmark');
return $this->react('DELETE', 'commits/'.rawurlencode($id).'/bookmark', saving: true);
}

/**
* The commits you saved, last saved first, as the web's Saved page lists them.
*
* @return Page<Commit>
*/
public function saved(?string $cursor = null): Page
{
return Page::fromArray($this->get('saved', ['cursor' => $cursor]), Commit::fromArray(...));
}

/**
Expand Down Expand Up @@ -314,12 +324,17 @@ private function get(string $path, array $query = []): array

/**
* Stars, unstars, saves or unsaves, and gives every kept copy of the commit the new counts.
* Saving or unsaving puts it on top of your kept saved commits, or takes it out.
*/
private function react(string $method, string $path): Commit
private function react(string $method, string $path, bool $saving = false): Commit
{
$commit = $this->data($this->send($method, $path));
$this->cache->commitReacted($commit);

if ($saving) {
$this->cache->commitSaved($commit);
}

return Commit::fromArray($commit);
}

Expand Down
44 changes: 44 additions & 0 deletions app/Devnull/ResponseCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class ResponseCache
'#^commits/[^/]+/replies$#',
'#^users/[^/]+$#',
'#^users/[^/]+/commits$#',
'#^saved$#',
];

/**
Expand All @@ -65,6 +66,11 @@ final class ResponseCache
*/
private const int RememberedDeletions = 50;

/**
* How many of the commits unsaved on this phone it remembers.
*/
private const int RememberedUnsaves = 50;

/**
* What every copy of a user has of their profile, as a commit's author, a
* notification's actor or a profile's header.
Expand Down Expand Up @@ -224,6 +230,44 @@ public function commitReacted(array $commit): void
$this->patchCommit($id, fn (array $kept): array => [...$kept, ...$reactions]);
}

/**
* You saved or unsaved a commit. A commit you saved is the last you saved,
* so it goes on top of your saved commits; one you unsaved leaves them,
* and is remembered for the pages of them loaded past the first.
*
* @param array<string, mixed> $commit
*/
public function commitSaved(array $commit): void
{
$id = (string) ($commit['id'] ?? '');
$userId = $this->userId();

if ($id === '' || $userId === null || ! is_bool($commit['is_saved'] ?? null)) {
return;
}

$this->patch('saved', [], fn (array $json): array => $commit['is_saved']
? self::prepend($json, $commit)
: [...$json, 'data' => self::without($json['data'] ?? [], $id)]);

$unsaved = array_diff($this->unsavedIds(), [$id]);
$this->store()->forever("{$userId} unsaved", array_slice($commit['is_saved'] ? $unsaved : [$id, ...$unsaved], 0, self::RememberedUnsaves));
}

/**
* Commits unsaved on this phone lately, and not saved again since, for a
* screen showing more of your saved commits than the first page kept.
*
* @return list<string>
*/
public function unsavedIds(): array
{
$userId = $this->userId();
$ids = $userId === null ? null : $this->store()->get("{$userId} unsaved");

return is_array($ids) ? array_values(array_filter($ids, is_string(...))) : [];
}

/**
* You committed. A commit goes on top of your logs and your profile; a reply
* goes under its parent and counts there, and on top of your replies.
Expand Down
2 changes: 2 additions & 0 deletions app/Icons/Lucide.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ enum Lucide
case Ellipsis;
case Flag;
case RefreshCw;
case LockKeyhole;

// The achievement badges, named as devnull names their icons (see forBadge()).
case Terminal;
Expand Down Expand Up @@ -128,6 +129,7 @@ public function custom(bool $filled = false): ?string
self::Ellipsis => 'ellipsis',
self::Flag => 'flag',
self::RefreshCw => 'refresh-cw',
self::LockKeyhole => 'lock-keyhole',
self::Terminal => 'terminal',
self::FileCode => 'file-code',
self::Flame => 'flame',
Expand Down
10 changes: 6 additions & 4 deletions app/NativeComponents/Layouts/TabsLayout.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@
use Native\Mobile\Edge\NativeComponent;

/**
* The three top-level sections: Log, Notifications and your Profile, with the
* icons of the web's phone nav, picked out in the colour you chose for your
* lane. Notifications wears a badge with the unread count devnull last gave.
* The top-level sections: Log, Notifications, Saved and your Profile, with the
* icons of the web's nav, picked out in the colour you chose for your lane.
* Notifications wears a badge with the unread count devnull last gave. The
* web's phone keeps Saved in its account menu; here it's a tab of its own.
*/
class TabsLayout extends NativeLayout
{
/**
* Each tab's URI. Commits and profiles open beneath the one they were tapped in.
*/
public const array Tabs = ['/log', '/notifications', '/profile'];
public const array Tabs = ['/log', '/notifications', '/saved', '/profile'];

/**
* The icon and "/dev/null" wordmark as the web's MobileHeader draws them, 105 x 28pt.
Expand Down Expand Up @@ -81,6 +82,7 @@ public function tabBar(NativeComponent $screen): ?TabBar
->labelVisibility('labeled')
->add(Tab::link('Log', '/log', ios: Lucide::GitCommitVertical->ios(), android: Lucide::GitCommitVertical->android()))
->add($notifications)
->add(Tab::link('Saved', '/saved', ios: Lucide::Bookmark->ios(), android: Lucide::Bookmark->android()))
->add(Tab::link('Profile', '/profile', ios: Lucide::UserRound->ios(), android: Lucide::UserRound->android()));
}

Expand Down
244 changes: 244 additions & 0 deletions app/NativeComponents/Saved.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
<?php

namespace App\NativeComponents;

use App\Devnull\Data\Commit;
use App\Devnull\Data\Page;
use App\Devnull\ResponseCache;
use App\Devnull\ShownTabs;
use App\Devnull\Timeline;
use App\NativeComponents\Concerns\ExpandsThreads;
use App\NativeComponents\Concerns\OpensInTab;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\ShowsCommits;
use App\NativeComponents\Concerns\TalksToDevnull;
use App\NativeComponents\Layouts\TabsLayout;
use Closure;
use Illuminate\View\View;
use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Edge\Transition;

/**
* The commits you saved, last saved first, as the web's Saved page lists them:
* the Log's rows, with a day marker wherever the day of the commits changes.
* Only you see them.
*
* Unsaving one here takes its row away once devnull says it's done, as on the
* web. Saving or unsaving anywhere else changes the kept list too (see
* ResponseCache::commitSaved), and a tab tap mounts this screen afresh, so it
* shows the next time you open it.
*
* It opens on the list as it was last time, then asks devnull behind it. Only
* you change it, so what devnull sends goes straight in: there's no pill.
*/
class Saved extends NativeComponent
{
use ExpandsThreads;
use OpensInTab;
use RefreshesInBackground;
use ShowsCommits {
toggleBookmark as private toggleSaved;
}
use TalksToDevnull;

/**
* Every frame goes in full, as on the Log (see Log::$forceFullFrames), since rows leave the list from the middle.
*/
protected bool $forceFullFrames = true;

/** @var list<Commit> */
public array $commits = [];

public ?string $nextCursor = null;

/**
* Whether the list has had commits to show, kept or fresh. Until then it shows skeleton rows.
*/
public bool $loaded = false;

public function mount(): void
{
$this->showKept();
$this->revalidate();
}

/**
* Back from a commit or a profile: take any change made there from the cache at once, then ask devnull.
*/
public function onResume(): void
{
$this->forgetRefreshes();
$this->forgetGone();
$this->showKept();
$this->revalidate();
}

public function navTitle(): string
{
return 'Saved';
}

/**
* Pull to refresh.
*/
public function load(): void
{
$this->refresh('saved', [], fn (array $json) => $this->takeFresh($json), again: true);
$this->refreshThreads();
$this->refreshUnreadNotifications();
}

protected function revalidate(): void
{
$this->refresh('saved', [], fn (array $json) => $this->takeFresh($json));
$this->refreshThreads();
$this->refreshUnreadNotifications();
}

protected function showsContent(): bool
{
return $this->loaded;
}

public function loadMore(): void
{
if ($this->nextCursor === null) {
return;
}

$page = $this->attempt(fn ($devnull) => $devnull->saved($this->nextCursor));

if ($page !== null) {
$this->commits = [...$this->commits, ...self::without($page->items, self::ids($this->commits))];
$this->nextCursor = $page->nextCursor;
}
}

/**
* Saves or unsaves straight away, as anywhere else. A commit you unsaved
* leaves the list once devnull says so. When it was the last one showing
* and there are more, the list starts again from the top, as on the web.
*/
public function toggleBookmark(string $id): void
{
$this->toggleSaved($id);

$row = array_values(array_filter($this->commits, fn (Commit $commit): bool => $commit->id === $id))[0] ?? null;

// A reply under a saved commit can be unsaved too, and it isn't a row.
if ($row?->isSaved !== false) {
return;
}

$this->commits = self::without($this->commits, [$id]);
unset($this->threads[$id]);

if ($this->commits === [] && $this->nextCursor !== null) {
$this->nextCursor = null;
$this->loaded = false;
$this->load();
}
}

/**
* The empty list's Explore Trending: the Log, open on Trending, as its tab opens it.
*/
public function exploreTrending(): void
{
app(ShownTabs::class)->remember($this->me()?->id, 'log', Log::Ranked);

$this->replace(TabsLayout::Tabs[0])->transition(Transition::None);
}

/**
* @return iterable<Commit>
*/
protected function shownCommits(): iterable
{
return [...$this->commits, ...$this->threadCommits()];
}

protected function updateShownCommits(Closure $update): void
{
$this->commits = array_map($update, $this->commits);
$this->threads = array_map(fn (array $replies): array => array_map($update, $replies), $this->threads);
}

private function showKept(): void
{
if (($json = $this->cached('saved')) !== null) {
$this->takeFresh($json);
}
}

/**
* Commits unsaved or deleted on this phone leave the list, wherever they
* are in it. A kept first page only knows about its own.
*/
private function forgetGone(): void
{
$cache = app(ResponseCache::class);

$this->commits = self::without($this->commits, [...$cache->unsavedIds(), ...$cache->deletedIds()]);
}

/**
* A fresh first page replaces the top of the list. The pages loaded below
* it stay, from the last commit the fresh page also has, unless it's the
* whole list. Saving a commit again moves it to the top, so rows are
* matched from the end of the page, where the order hasn't changed.
*
* @param array<string, mixed> $json
*/
private function takeFresh(array $json): void
{
$page = Page::fromArray($json, Commit::fromArray(...));
$shownAt = array_flip(self::ids($this->commits));
$later = [];

foreach ($page->nextCursor === null ? [] : array_reverse($page->items) as $commit) {
if (isset($shownAt[$commit->id])) {
$later = self::without(array_slice($this->commits, $shownAt[$commit->id] + 1), self::ids($page->items));

break;
}
}

$this->commits = [...$page->items, ...$later];
$this->nextCursor = $later === [] ? $page->nextCursor : $this->nextCursor;
$this->loaded = true;
}

/**
* @param list<Commit> $commits
* @param list<string> $ids
* @return list<Commit>
*/
private static function without(array $commits, array $ids): array
{
$leaving = array_flip($ids);

return array_values(array_filter($commits, fn (Commit $commit): bool => ! isset($leaving[$commit->id])));
}

/**
* @param list<Commit> $commits
* @return list<string>
*/
private static function ids(array $commits): array
{
return array_map(fn (Commit $commit): string => $commit->id, $commits);
}

public function render(): View
{
$lastCommit = $this->commits[array_key_last($this->commits) ?? -1] ?? null;

return view('native.saved', [
'viewer' => $this->me(),
'myColour' => $this->myColour(),
'rows' => Timeline::feed($this->commits),
'lastCommit' => $lastCommit,
]);
}
}
Loading
Loading