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
53 changes: 51 additions & 2 deletions app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Devnull;

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

Expand All @@ -19,6 +20,13 @@ final class Account

private const string PasteisKey = 'devnull.pasteis';

private const string RemotesKey = 'devnull.remotes';

/**
* What's kept while devnull has said you have Remotes off, as against not having said yet.
*/
private const string RemotesOff = 'off';

public function user(): ?User
{
$attributes = Cache::get(self::CacheKey);
Expand Down Expand Up @@ -72,8 +80,48 @@ public function rememberPasteis(?Pasteis $pasteis): void
}

/**
* Signs this phone out: who it was, the badge, its pastéis, every answer kept for a quick start, and your unsent
* drafts.
* Your Remotes as devnull last said, or null while you have them off. Until devnull has said, they're on, as
* they are for everyone to start with.
*/
public function remotes(): ?Remotes
{
$kept = Cache::get(self::RemotesKey);

return match (true) {
$kept === self::RemotesOff => null,
is_array($kept) => Remotes::fromArray($kept),
default => new Remotes,
};
}

/**
* Remembers your Remotes, or with null that you have them off.
*/
public function rememberRemotes(?Remotes $remotes): void
{
Cache::forever(self::RemotesKey, $remotes?->toArray() ?? self::RemotesOff);
}

/**
* How many messages and requests were waiting when devnull last said, for the Remotes tab's badge.
*/
public function unreadRemotes(): int
{
return $this->remotes()->unreadCount ?? 0;
}

public function rememberUnreadRemotes(int $count): void
{
$remotes = $this->remotes();

if ($remotes !== null) {
$this->rememberRemotes($remotes->withUnreadCount($count));
}
}

/**
* Signs this phone out: who it was, the badges, its pastéis and Remotes, every answer kept for a quick start,
* and your unsent drafts.
*/
public function forget(): void
{
Expand All @@ -83,5 +131,6 @@ public function forget(): void
Cache::forget(self::CacheKey);
Cache::forget(self::UnreadKey);
Cache::forget(self::PasteisKey);
Cache::forget(self::RemotesKey);
}
}
86 changes: 86 additions & 0 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,39 @@
*/
public const string PasteisModePath = 'me/pasteis-mode';

/**
* Your Remotes inbox, and where Remotes are switched on and off and their settings saved, as on the web.
*/
public const string RemotesPath = 'remotes';

public const string RemotesSwitchPath = 'me/remotes';

public const string RemotePreferencesPath = 'me/remote-preferences';

/** Who you could start a remote or an org with, as the web's pickers ask. */
public const string RemoteCandidatesPath = 'remotes/candidates';

public const string OrgsPath = 'remotes/orgs';

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

/**
* Where a Remotes thread lives: with a person, by their username, or an org, by its id. Its messages are pushed
* to the same path, and its read, typing and stash live beside it.
*/
public static function threadPath(string $username = '', ?string $orgId = null): string
{
return $orgId === null ? 'remotes/@'.rawurlencode($username) : 'remotes/orgs/'.rawurlencode($orgId);
}

/**
* Where a message's reactions are left and taken back.
*/
public static function reactionsPath(int $messageId): string
{
return 'remotes/messages/'.$messageId.'/reactions';
}

/**
* Where a pastel is given to a commit, one per request.
*/
Expand Down Expand Up @@ -212,6 +243,61 @@ public function saved(?string $cursor = null): Page
return Page::fromArray($this->get('saved', ['cursor' => $cursor]), Commit::fromArray(...));
}

/**
* A page of your Remotes inbox, newest first, for the web's Open, Fetched
* or Stashed tab, continuing before a conversation.
*
* @return array<string, mixed>
*/
public function remotes(?string $tab = null, ?string $before = null): array
{
return $this->get(self::RemotesPath, ['tab' => $tab, 'before' => $before]);
}

/**
* People matching a term, each with the web's reason you can't message
* them, or add them to a new org, when there is one. Never kept: who you
* can reach changes as people follow, block and turn Remotes off.
*
* @return list<array{user: User, reason: string|null}>
*/
public function remoteCandidates(string $term, string $for): array
{
$candidates = $this->json($this->send('GET', self::RemoteCandidatesPath, ['q' => $term, 'for' => $for]))['candidates'] ?? null;

return array_values(array_map(
fn (array $candidate): array => [
'user' => User::fromArray($candidate['user']),
'reason' => is_string($candidate['reason'] ?? null) ? $candidate['reason'] : null,
],
array_filter(is_array($candidates) ? $candidates : [], fn (mixed $candidate): bool => is_array($candidate) && is_array($candidate['user'] ?? null)),
));
}

/**
* Starts an org, as the web's New org does: you maintain it, and the
* people named contribute. Answers its id.
*
* @param list<string> $members
*/
public function createOrg(?string $name, array $members): string
{
$org = $this->json($this->send('POST', self::OrgsPath, payload: ['name' => $name, 'members' => $members]))['org'] ?? null;

return is_array($org) && is_string($org['id'] ?? null) ? $org['id'] : '';
}

/**
* The messages of a Remotes thread before one, as the web loads them when
* you scroll back.
*
* @return array<string, mixed>
*/
public function olderMessages(string $threadPath, int $before): array
{
return $this->get($threadPath.'/messages', ['before' => (string) $before]);
}

/**
* Reports a commit to devnull's moderators, privately, as the web's Report
* button does. Nothing on the commit changes, so nothing kept changes
Expand Down
110 changes: 110 additions & 0 deletions app/Devnull/Data/Conversation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace App\Devnull\Data;

use Carbon\CarbonImmutable;

/**
* A row of the Remotes inbox, as devnull sends it: the person you talk to, or
* an org's name and members, the last message, how many messages from others
* you haven't pulled yet (the web's "ahead"), how far the partner pulled when
* you both share pulls, and whether you stashed it.
*/
final readonly class Conversation
{
/**
* @param list<array{user: User, role: ?string}> $members
*/
public function __construct(
public string $id,
public string $kind,
public ?string $name,
public ?User $partner,
public array $members,
public ?Message $lastMessage,
public ?CarbonImmutable $lastMessageAt,
public int $ahead = 0,
public int $partnerReadThroughId = 0,
public bool $isArchived = false,
) {}

/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
$members = [];

foreach (is_array($data['members'] ?? null) ? $data['members'] : [] as $member) {
if (is_array($member) && is_array($member['user'] ?? null)) {
$members[] = ['user' => User::fromArray($member['user']), 'role' => is_string($member['role'] ?? null) ? $member['role'] : null];
}
}

return new self(
id: (string) ($data['id'] ?? ''),
kind: ($data['kind'] ?? 'direct') === 'org' ? 'org' : 'direct',
name: is_string($data['name'] ?? null) && $data['name'] !== '' ? $data['name'] : null,
partner: is_array($data['partner'] ?? null) ? User::fromArray($data['partner']) : null,
members: $members,
lastMessage: is_array($data['last_message'] ?? null) ? Message::fromArray($data['last_message']) : null,
lastMessageAt: Commit::date($data['last_message_at'] ?? null),
ahead: is_int($data['ahead'] ?? null) ? max(0, $data['ahead']) : 0,
partnerReadThroughId: is_int($data['partner_read_through_id'] ?? null) ? $data['partner_read_through_id'] : 0,
isArchived: ($data['is_archived'] ?? false) === true,
);
}

public function isOrg(): bool
{
return $this->kind === 'org';
}

/**
* The everyone else in an org, in the order they joined.
*
* @return list<User>
*/
public function others(?string $viewerId): array
{
return array_values(array_map(
fn (array $member): User => $member['user'],
array_filter($this->members, fn (array $member): bool => $member['user']->id !== $viewerId),
));
}

/**
* What the row and the thread are called: the partner's name, or an org's name, or else the first names of
* the others in it, as the web titles an org it hasn't named.
*/
public function title(?string $viewerId): string
{
if (! $this->isOrg()) {
return $this->partner->name ?? '';
}

if ($this->name !== null) {
return $this->name;
}

$names = array_map(fn (User $user): string => (string) strtok($user->name, ' '), $this->others($viewerId));

return $names === [] ? 'Just you' : implode(', ', $names);
}

/**
* Where its thread opens in the Remotes tab.
*/
public function path(): string
{
return $this->isOrg() ? '/orgs/'.rawurlencode($this->id) : '/conversations/'.rawurlencode($this->partner->username ?? '');
}

/**
* Whether the last message is yours and the partner has pulled it, which the row marks as the web does.
*/
public function lastPulled(): bool
{
return $this->lastMessage !== null && $this->partnerReadThroughId >= $this->lastMessage->id;
}
}
11 changes: 10 additions & 1 deletion app/Devnull/Data/CurrentUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
* custom colour 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 has whether pastéis mode is on, how
* many pastéis you can still give today, and how many others have given you.
* many pastéis you can still give today, and how many others have given you,
* and your Remotes settings, or null while you have Remotes off.
*/
final readonly class CurrentUser
{
Expand All @@ -28,6 +29,7 @@ public function __construct(
public bool $pasteisMode = false,
public Pasteis $pasteisToday = new Pasteis,
public int $pasteisReceived = 0,
public ?Remotes $remotes = null,
) {}

/**
Expand Down Expand Up @@ -55,6 +57,8 @@ public static function fromArray(array $json): self
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,
// Only devnull saying null means they're off: a devnull too old to say has them on, as everyone starts.
remotes: array_key_exists('remotes', $data) ? Remotes::fromArray($data['remotes']) : new Remotes,
);
}

Expand All @@ -79,6 +83,11 @@ public function withPasteisMode(bool $pasteisMode): self
return new self(...[...get_object_vars($this), 'pasteisMode' => $pasteisMode]);
}

public function withRemotes(?Remotes $remotes): self
{
return new self(...[...get_object_vars($this), 'remotes' => $remotes]);
}

/**
* Your GitHub Sponsors page as the web prints it, without the scheme.
*/
Expand Down
Loading
Loading