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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ composer setup

For now the app tracks unreleased branches, which Composer pulls straight from GitHub:

- `nativephp/mobile` on `feat/custom-icons`, which adds custom SVG icons ([NativePHP/mobile-air#544](https://github.com/NativePHP/mobile-air/pull/544)).
- `nativephp/mobile-ui` on `combined/dev-null-app`, a branch just for this app. It's `feat/custom-icons` ([NativePHP/mobile-ui#132](https://github.com/NativePHP/mobile-ui/pull/132)) plus the fix that lets a swiping pager's lists run under the tab bar ([NativePHP/mobile-ui#134](https://github.com/NativePHP/mobile-ui/pull/134)).
- `nativephp/mobile` on `combined/dev-null-app`, a branch just for this app. It's `feat/custom-icons`, which adds custom SVG icons ([NativePHP/mobile-air#544](https://github.com/NativePHP/mobile-air/pull/544)), plus the fix that stops the keyboard-dismiss tap swallowing taps on UIKit controls such as `native:toggle` and the Sign in with Apple button ([NativePHP/mobile-air#545](https://github.com/NativePHP/mobile-air/pull/545)).
- `nativephp/mobile-ui` on `combined/dev-null-app`, a branch just for this app. It's `feat/custom-icons` ([NativePHP/mobile-ui#132](https://github.com/NativePHP/mobile-ui/pull/132)) plus:
- opening the iOS pager at its start page ([#131](https://github.com/NativePHP/mobile-ui/pull/131)) and running a swiping pager's lists under the tab bar ([#134](https://github.com/NativePHP/mobile-ui/pull/134)),
- a pager that reports its page once the swipe settles ([#135](https://github.com/NativePHP/mobile-ui/pull/135)) and draws its tabs natively ([#136](https://github.com/NativePHP/mobile-ui/pull/136)),
- button variants named after a theme colour ([#137](https://github.com/NativePHP/mobile-ui/pull/137)),
- a sticky header for lists ([#138](https://github.com/NativePHP/mobile-ui/pull/138)),
- a pager header that scrolls with the page showing ([#139](https://github.com/NativePHP/mobile-ui/pull/139)).

Once those are released, the app goes back to tagged versions and the combined branch can be deleted.
Once those are released, the app goes back to tagged versions and the combined branches can be deleted.

### Without plugin credentials

Expand Down Expand Up @@ -74,4 +79,4 @@ php artisan test

## License

MIT. Geist and Geist Mono are under the SIL Open Font License, see `resources/fonts/OFL.txt`. The icons in `resources/icons` are from [Lucide](https://lucide.dev), as on the web, under the ISC License, see `resources/icons/LICENSE`.
MIT. Geist and Geist Mono are under the SIL Open Font License, see `resources/fonts/OFL.txt`. The icons in `resources/icons` are from [Lucide](https://lucide.dev), as on the web, under the ISC License, see `resources/icons/LICENSE`. The pastel de nata in `public/images` is drawn from the web's own `public/pastel-de-nata.svg`.
31 changes: 30 additions & 1 deletion app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Devnull;

use App\Devnull\Data\Pasteis;
use App\Devnull\Data\User;
use Illuminate\Support\Facades\Cache;

Expand All @@ -16,6 +17,8 @@ final class Account

private const string UnreadKey = 'devnull.unread-notifications';

private const string PasteisKey = 'devnull.pasteis';

public function user(): ?User
{
$attributes = Cache::get(self::CacheKey);
Expand Down Expand Up @@ -44,13 +47,39 @@ public function rememberUnreadNotifications(int $count): void
}

/**
* Signs this phone out: who it was, the badge, and every answer kept for a quick start.
* Your pastéis for today while pastéis mode is on, as devnull last said or
* as this phone has counted them since, or null while the mode is off.
*/
public function pasteis(): ?Pasteis
{
$kept = Cache::get(self::PasteisKey);

return is_array($kept) ? Pasteis::fromKept($kept) : null;
}

/**
* Remembers your pastéis, or with null that pastéis mode is off.
*/
public function rememberPasteis(?Pasteis $pasteis): void
{
if ($pasteis === null) {
Cache::forget(self::PasteisKey);

return;
}

Cache::forever(self::PasteisKey, $pasteis->toArray());
}

/**
* Signs this phone out: who it was, the badge, its pastéis, and every answer kept for a quick start.
*/
public function forget(): void
{
app(ResponseCache::class)->flush();

Cache::forget(self::CacheKey);
Cache::forget(self::UnreadKey);
Cache::forget(self::PasteisKey);
}
}
13 changes: 13 additions & 0 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,21 @@

public const string BlockedPath = 'me/blocked';

/**
* Where pastéis mode is switched on and off for the signed-in account.
*/
public const string PasteisModePath = 'me/pasteis-mode';

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

/**
* Where a pastel is given to a commit, one per request.
*/
public static function pastelPath(string $id): string
{
return 'commits/'.rawurlencode($id).'/pasteis';
}

/**
* Trades the code dev-null.sh sent back, with the PKCE verifier only this
* phone knows, for the user it approved this phone for. It's signed like
Expand Down
15 changes: 13 additions & 2 deletions app/Devnull/Data/Commit.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public function __construct(
public ?Commit $quotedCommit = null,
public ?array $previewContent = null,
public ?ReplyPreview $replyPreview = null,
public int $pasteisCount = 0,
) {}

/**
Expand Down Expand Up @@ -128,6 +129,7 @@ public static function fromArray(array $data): self
quotedCommit: is_array($data['quoted_commit'] ?? null) ? self::fromArray($data['quoted_commit']) : null,
previewContent: $previewSegments === null ? null : CommitBody::blocks($body, $previewSegments, $mentions),
replyPreview: is_array($data['reply_preview'] ?? null) ? ReplyPreview::fromArray($data['reply_preview']) : null,
pasteisCount: max(0, (int) ($data['pasteis_count'] ?? 0)),
);
}

Expand All @@ -141,14 +143,22 @@ public function withStar(bool $starred): self
return new self(...[...get_object_vars($this), 'starred' => $starred, 'starsCount' => max(0, $this->starsCount + $change)]);
}

/**
* This commit with $change more pastéis than it has, or fewer for a negative change.
*/
public function withMorePasteis(int $change): self
{
return new self(...[...get_object_vars($this), 'pasteisCount' => max(0, $this->pasteisCount + $change)]);
}

public function withSaved(bool $isSaved): self
{
return new self(...[...get_object_vars($this), 'isSaved' => $isSaved]);
}

/**
* This commit with the star and save state of a fresher copy of it, keeping
* everything else, such as the parent a list loaded with it.
* This commit with the star, save and pastéis state of a fresher copy of
* it, keeping everything else, such as the parent a list loaded with it.
*/
public function withReactionsFrom(self $fresh): self
{
Expand All @@ -157,6 +167,7 @@ public function withReactionsFrom(self $fresh): self
'starred' => $fresh->starred,
'starsCount' => $fresh->starsCount,
'isSaved' => $fresh->isSaved ?? $this->isSaved,
'pasteisCount' => $fresh->pasteisCount,
]);
}

Expand Down
22 changes: 21 additions & 1 deletion app/Devnull/Data/CurrentUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
* It also has what only you see in the web's profile settings: the lane you
* chose (null for Automatic) and the one Automatic gives you, your GitHub
* Sponsors page, and when your profile was last synced from GitHub (null when
* it only was when you signed in).
* it only was when you signed in). It has whether pastéis mode is on, how
* many pastéis you can still give today, and how many others have given you.
*/
final readonly class CurrentUser
{
Expand All @@ -24,6 +25,9 @@ public function __construct(
public int $automaticLane = 0,
public ?string $sponsorsUrl = null,
public ?CarbonImmutable $githubSyncedAt = null,
public bool $pasteisMode = false,
public Pasteis $pasteisToday = new Pasteis,
public int $pasteisReceived = 0,
) {}

/**
Expand All @@ -48,6 +52,9 @@ public static function fromArray(array $json): self
automaticLane: $automaticLane,
sponsorsUrl: is_string($sponsorsUrl) && preg_match('#^https://#i', $sponsorsUrl) === 1 ? $sponsorsUrl : null,
githubSyncedAt: Commit::date($data['github_synced_at'] ?? null),
pasteisMode: ($data['pasteis_mode'] ?? false) === true,
pasteisToday: Pasteis::fromKept(['remaining' => $data['pasteis_remaining'] ?? null, 'resets_at' => $data['pasteis_resets_at'] ?? null]),
pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0,
);
}

Expand All @@ -59,6 +66,19 @@ public static function remembered(User $user): self
return self::fromArray(['data' => $user->toArray()]);
}

/**
* Your pastéis for today, or null while pastéis mode is off.
*/
public function pasteis(): ?Pasteis
{
return $this->pasteisMode ? $this->pasteisToday : null;
}

public function withPasteisMode(bool $pasteisMode): self
{
return new self(...[...get_object_vars($this), 'pasteisMode' => $pasteisMode]);
}

/**
* Your GitHub Sponsors page as the web prints it, without the scheme.
*/
Expand Down
123 changes: 123 additions & 0 deletions app/Devnull/Data/Pasteis.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace App\Devnull\Data;

use Carbon\CarbonImmutable;

/**
* Your pastéis for today, while pastéis mode is on: how many you can still
* give to other people's commits, and when the day's allowance comes back.
*
* As on the web, everyone gets ten a day, one per tap, and the allowance
* resets at 00:00 UTC rather than 24 hours after the first one went. So once
* that moment has passed, you have all ten again, whatever was kept before.
*/
final readonly class Pasteis
{
public const int Daily = 10;

/**
* The web's pastel de nata, drawn from its SVG at three times 20pt. It's in
* colour, so it ships as a picture rather than a tinted icon.
*/
public const string Image = 'images/pastel-de-nata@3x.png';

/**
* What the web says when you have none left, word for word.
*/
public const string Exhausted = 'you ran out of pastéis. wait until tomorrow.';

/**
* What the web says when a pastel didn't go, for any other reason.
*/
public const string CouldNotGive = 'Could not award a pastel. Please try again.';

public function __construct(public int $remaining = self::Daily, public ?CarbonImmutable $resetsAt = null) {}

/**
* @param array<string, mixed> $kept
*/
public static function fromKept(array $kept): self
{
$remaining = $kept['remaining'] ?? null;

return new self(is_int($remaining) ? max(0, min(self::Daily, $remaining)) : self::Daily, Commit::date($kept['resets_at'] ?? null));
}

/**
* How many you can give now: all ten once the day they were counted for is over.
*/
public function left(): int
{
return $this->today()->remaining;
}

/**
* After one more goes, before devnull has said so.
*/
public function afterGiving(): self
{
$today = $this->today();

return new self(max(0, $today->remaining - 1), $today->resetsAt);
}

/**
* After a pastel that didn't go is handed back.
*/
public function afterTakingBack(): self
{
$today = $this->today();

return new self(min(self::Daily, $today->remaining + 1), $today->resetsAt);
}

/**
* None left until the reset, as devnull said when it turned one down.
*/
public function exhausted(): self
{
return new self(0, $this->today()->resetsAt);
}

/**
* What devnull said is left after a pastel went. With others still on
* their way, the fewer of the two is the truer, unless devnull has moved
* on to another day.
*/
public function settledWith(int $remaining, ?CarbonImmutable $resetsAt): self
{
$today = $this->today();
$sameDay = $resetsAt === null || $resetsAt->equalTo($today->resetsAt);

return new self(max(0, $sameDay ? min($today->remaining, $remaining) : $remaining), $resetsAt ?? $today->resetsAt);
}

/**
* @return array{remaining: int, resets_at: ?string}
*/
public function toArray(): array
{
return ['remaining' => $this->remaining, 'resets_at' => $this->resetsAt?->toIso8601ZuluString()];
}

/**
* These pastéis as of now: a fresh ten once the reset has passed.
*/
private function today(): self
{
if ($this->resetsAt === null) {
return new self($this->remaining, self::nextReset());
}

return CarbonImmutable::now()->greaterThanOrEqualTo($this->resetsAt) ? new self(self::Daily, self::nextReset()) : $this;
}

/**
* The next 00:00 UTC, when the web gives everyone ten again.
*/
private static function nextReset(): CarbonImmutable
{
return CarbonImmutable::now('UTC')->addDay()->startOfDay();
}
}
6 changes: 5 additions & 1 deletion app/Devnull/Data/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ public function __construct(
public Activity $activity = new Activity,
public bool $isMuting = false,
public ?Achievements $achievements = null,
public ?int $pasteisReceived = null,
) {}

/**
* Reads a `GET /users/{username}` response: the user and counts in `data`,
* and alongside it whether you follow or mute them, their pinned commit,
* their streaks, their activity and their achievements.
* their streaks, their activity, their achievements and how many pastéis
* they have been given (null when devnull doesn't say).
*
* @param array<string, mixed> $json
*/
Expand All @@ -30,6 +32,7 @@ public static function fromArray(array $json): self
$data = is_array($json['data'] ?? null) ? $json['data'] : $json;
$counts = is_array($data['counts'] ?? null) ? $data['counts'] : [];
$pinned = $json['pinned_commit'] ?? $data['pinned_commit'] ?? null;
$received = $json['pasteis_received'] ?? $data['pasteis_received'] ?? null;

return new self(
user: User::fromArray(is_array($data['user'] ?? null) ? $data['user'] : $data),
Expand All @@ -43,6 +46,7 @@ public static function fromArray(array $json): self
activity: Activity::fromArray($json['activity'] ?? $data['activity'] ?? null),
isMuting: ($json['is_muting'] ?? $data['is_muting'] ?? false) === true,
achievements: Achievements::fromArray($json['achievements'] ?? $data['achievements'] ?? null),
pasteisReceived: is_int($received) ? max(0, $received) : null,
);
}

Expand Down
4 changes: 4 additions & 0 deletions app/Devnull/ErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace App\Devnull;

use App\Devnull\Data\Pasteis;

/**
* The `code` devnull sends next to an error's message, for the errors the app
* has to tell apart. A code never changes once released, where the wording
Expand All @@ -22,6 +24,7 @@ enum ErrorCode: string
case OwnCommit = 'own_commit';
case CommitUnavailable = 'commit_unavailable';
case GitHubSyncFailed = 'github_sync_failed';
case PasteisExhausted = 'pasteis_exhausted';

/**
* What the app says for it, or null where devnull's own message says it
Expand All @@ -42,6 +45,7 @@ public function message(): ?string
self::UserUnavailable => "fatal: this profile isn't available.",
self::CommitUnavailable => "fatal: this commit isn't available.",
self::GitHubSyncFailed => 'Could not sync from GitHub. Try again in a minute.',
self::PasteisExhausted => Pasteis::Exhausted,
self::RateLimited, self::OwnCommit => null,
};
}
Expand Down
Loading
Loading