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
59 changes: 59 additions & 0 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
*/
public const string LanePath = 'me/lane';

/**
* The people the signed-in account mutes, and blocks, as the web's
* Settings → Safety lists them.
*/
public const string MutedPath = 'me/muted';

public const string BlockedPath = 'me/blocked';

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

/**
Expand Down Expand Up @@ -252,6 +260,57 @@ public function unfollow(string $username): void
$this->cache->followChanged(rawurlencode($username), false);
}

/**
* Mutes someone, as the web's Mute does. It's silent: they aren't told,
* and the follows between you stay. Their commits leave your logs.
*/
public function mute(string $username): void
{
$this->cache->muteChanged($this->json($this->send('POST', 'users/'.rawurlencode($username).'/mute')));
}

public function unmute(string $username): void
{
$this->cache->muteChanged($this->json($this->send('DELETE', 'users/'.rawurlencode($username).'/mute')));
}

/**
* Blocks someone, as the web's Block does, which ends the follows both
* ways. Neither of you sees the other's profile or commits after it.
*/
public function block(string $username): void
{
$this->cache->blockChanged($this->json($this->send('POST', 'users/'.rawurlencode($username).'/block')));
}

/**
* Unblocking doesn't bring back the follows, as on the web.
*/
public function unblock(string $username): void
{
$this->cache->blockChanged($this->json($this->send('DELETE', 'users/'.rawurlencode($username).'/block')));
}

/**
* The people you mute, last muted first.
*
* @return Page<User>
*/
public function muted(?string $cursor = null): Page
{
return Page::fromArray($this->get(self::MutedPath, ['cursor' => $cursor]), User::fromArray(...));
}

/**
* The people you block, last blocked first.
*
* @return Page<User>
*/
public function blocked(?string $cursor = null): Page
{
return Page::fromArray($this->get(self::BlockedPath, ['cursor' => $cursor]), User::fromArray(...));
}

/**
* Your notifications grouped as the web groups them, filtered by the web's
* ?type= (all, replies, quotes, stars, mentions, follows or moderation).
Expand Down
5 changes: 5 additions & 0 deletions app/Devnull/Data/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public function withFollowing(bool $isFollowing): self
return new self(...[...get_object_vars($this), 'followersCount' => max(0, $this->followersCount + $change), 'isFollowing' => $isFollowing]);
}

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

public function withPinnedCommit(?Commit $pinnedCommit): self
{
return new self(...[...get_object_vars($this), 'pinnedCommit' => $pinnedCommit]);
Expand Down
6 changes: 4 additions & 2 deletions app/Devnull/Exceptions/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Devnull\Exceptions;

use App\Devnull\Data\User;
use App\Devnull\ErrorCode;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\Response;
Expand All @@ -19,8 +20,9 @@ class ApiException extends RuntimeException
* @param array<string, list<string>> $errors
* @param int|null $retryAfter How many seconds the server asked to wait before trying again.
* @param string|null $errorCode The machine-readable code devnull sends next to the message, such as "device_not_approved".
* @param User|null $user The user devnull sends with the error, as it sends the one you blocked with user_blocked.
*/
public function __construct(string $message, public readonly int $status = 0, public readonly array $errors = [], public readonly ?int $retryAfter = null, public readonly ?string $errorCode = null)
public function __construct(string $message, public readonly int $status = 0, public readonly array $errors = [], public readonly ?int $retryAfter = null, public readonly ?string $errorCode = null, public readonly ?User $user = null)
{
parent::__construct($message, $status);
}
Expand Down Expand Up @@ -51,7 +53,7 @@ public static function fromParts(int $status, mixed $json, string $retryAfter =
return match ($status) {
401 => new Unauthenticated($message, 401, errorCode: $code),
422 => new ApiException($message, 422, $errors, errorCode: $code),
default => new ApiException($message, $status, retryAfter: self::retryAfter($retryAfter), errorCode: $code),
default => new ApiException($message, $status, retryAfter: self::retryAfter($retryAfter), errorCode: $code, user: is_array($json['user'] ?? null) ? User::fromArray($json['user']) : null),
};
}

Expand Down
177 changes: 177 additions & 0 deletions app/Devnull/ResponseCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ final class ResponseCache
*/
private const array Cacheable = [
'#^me$#',
'#^me/muted$#',
'#^me/blocked$#',
'#^notifications$#',
'#^notifications/[^/]+/group$#',
'#^log/[^/]+$#',
Expand Down Expand Up @@ -81,6 +83,11 @@ final class ResponseCache
*/
private const int RememberedUnsaves = 50;

/**
* How many of the people muted or blocked on this phone it remembers, until a log has taken their commits out.
*/
private const int RememberedHidden = 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 @@ -390,6 +397,131 @@ private function patchFollowedPeople(string $username, bool $following): void
}
}

/**
* You muted or unmuted someone, and devnull answered with them and
* whether you mute them now: their profile says so, and they join the top
* of the people you mute, or leave it. Once muted, their commits leave
* your logs, as devnull leaves them out. Unmuted, they come back with the
* next refresh.
*
* @param array<string, mixed> $json
*/
public function muteChanged(array $json): void
{
[$user, $muting] = self::changedUser($json, 'is_muting');

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

$this->patch('users/'.rawurlencode($user['username']), [], fn (array $kept): array => [...$kept, 'is_muting' => $muting]);
$this->patch(Client::MutedPath, [], fn (array $kept): array => self::listed($kept, $user, $muting));

$this->rememberHidden($user['id'], $muting);

if ($muting) {
$this->dropFromLogs($user['id']);
}
}

/**
* You blocked or unblocked someone, and devnull answered with them and
* whether you block them now: they join the top of the people you block,
* or leave it. Across a block neither of you sees the other's profile or
* commits, and the follows between you end, so their profile and its
* commits go, their commits leave your logs, and your own counts lose
* the follows. Unblocking doesn't bring the follows back.
*
* @param array<string, mixed> $json
*/
public function blockChanged(array $json): void
{
[$user, $blocking] = self::changedUser($json, 'is_blocking');

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

$this->patch(Client::BlockedPath, [], fn (array $kept): array => self::listed($kept, $user, $blocking));
$this->rememberHidden($user['id'], $blocking);

if (! $blocking) {
return;
}

$path = 'users/'.rawurlencode($user['username']);
$profile = $this->get($path);

if ($profile !== null && ($me = $this->username()) !== null) {
$this->patch("users/{$me}", [], fn (array $kept): array => self::count(
self::count($kept, 'following_count', ($profile['is_following'] ?? false) === true ? -1 : 0),
'followers_count',
($profile['follows_you'] ?? false) === true ? -1 : 0,
));
}

$this->forget($path);
$this->forget("{$path}/commits");
$this->forget("{$path}/commits", ['replies' => '1']);
$this->dropFromLogs($user['id']);
}

/**
* The user a mute or block answered with, and the state devnull says it left.
*
* @param array<string, mixed> $json
* @return array{0: array{id: string, username: string}|null, 1: bool}
*/
private static function changedUser(array $json, string $state): array
{
$user = is_array($json['data'] ?? null) ? $json['data'] : [];

if (! is_string($user['id'] ?? null) || $user['id'] === '' || ! is_string($user['username'] ?? null) || $user['username'] === '') {
return [null, false];
}

return [$user, ($json[$state] ?? null) === true];
}

/**
* Takes someone's commits out of every kept log, and out of what an empty
* Following log suggests.
*/
private function dropFromLogs(string $authorId): void
{
$userId = $this->userId();

foreach ($userId === null ? [] : $this->index($userId) as $meta) {
if (! str_starts_with($meta['path'], 'log/')) {
continue;
}

$this->patch($meta['path'], $meta['query'], function (array $json) use ($authorId): array {
foreach (['data', 'suggestions'] as $list) {
if (is_array($json[$list] ?? null)) {
$json[$list] = array_values(array_filter($json[$list], fn (mixed $commit): bool => ! is_array($commit) || ($commit['author']['id'] ?? null) !== $authorId));
}
}

return $json;
});
}
}

/**
* A kept list of people, with $user on top, or without them.
*
* @param array<string, mixed> $json
* @param array{id: string} $user
* @return array<string, mixed>
*/
private static function listed(array $json, array $user, bool $listed): array
{
$without = self::without($json['data'] ?? [], $user['id']);

return [...$json, 'data' => $listed ? [$user, ...$without] : $without];
}

/**
* Commits deleted on this phone lately, for a ranked log, which can't tell
* a commit that went from one that only fell down the ranking.
Expand All @@ -404,6 +536,51 @@ public function deletedIds(): array
return is_array($ids) ? array_values(array_filter($ids, is_string(...))) : [];
}

/**
* The people muted or blocked on this phone since a log last asked, whose
* commits it may still be showing from before: Trending keeps what it
* shows in place, and pages further down aren't kept to patch. Asking
* forgets them, so a mute undone on the web hides nothing for long.
*
* @return list<string>
*/
public function takeHiddenAuthorIds(): array
{
$userId = $this->userId();

if ($userId === null) {
return [];
}

$ids = $this->hiddenAuthorIds($userId);
$this->store()->forget("{$userId} hidden");

return $ids;
}

/**
* @return list<string>
*/
private function hiddenAuthorIds(string $userId): array
{
$ids = $this->store()->get("{$userId} hidden");

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

private function rememberHidden(string $authorId, bool $hidden): void
{
$userId = $this->userId();

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

$others = array_values(array_diff($this->hiddenAuthorIds($userId), [$authorId]));

$this->store()->forever("{$userId} hidden", array_slice($hidden ? [$authorId, ...$others] : $others, 0, self::RememberedHidden));
}

private function rememberDeletion(string $id): void
{
$userId = $this->userId();
Expand Down
6 changes: 6 additions & 0 deletions app/Icons/Lucide.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ enum Lucide
case Flag;
case RefreshCw;
case LockKeyhole;
case VolumeX;
case Volume2;
case Ban;

// The achievement badges, named as devnull names their icons (see forBadge()).
case Terminal;
Expand Down Expand Up @@ -130,6 +133,9 @@ public function custom(bool $filled = false): ?string
self::Flag => 'flag',
self::RefreshCw => 'refresh-cw',
self::LockKeyhole => 'lock-keyhole',
self::VolumeX => 'volume-x',
self::Volume2 => 'volume-2',
self::Ban => 'ban',
self::Terminal => 'terminal',
self::FileCode => 'file-code',
self::Flame => 'flame',
Expand Down
10 changes: 10 additions & 0 deletions app/NativeComponents/Concerns/ShowsCommits.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,16 @@ public function confirmDelete(string $id): void

#[On(ButtonPressed::class)]
public function onAlertButton(string $label = '', ?string $id = null): void
{
$this->answerDeleteAlert($label, $id);
}

/**
* Deletes the commit the delete alert asked about, once you pick Delete. A
* screen with alerts of its own answers them in its own onAlertButton(),
* and passes the delete alert on to this.
*/
protected function answerDeleteAlert(string $label, ?string $id): void
{
if ($id !== self::DeleteAlert || $this->deleting === null) {
return;
Expand Down
19 changes: 19 additions & 0 deletions app/NativeComponents/Log.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ public function onResume(): void
$this->forgetRefreshes();
$this->closeEmptyComposer();
$this->forgetDeleted();
$this->forgetHidden();
$this->showKept();
$this->revalidate();
}
Expand Down Expand Up @@ -640,6 +641,24 @@ private function forgetDeleted(): void
$this->latestCommits = array_values(array_filter($this->latestCommits, fn (Commit $commit): bool => ! isset($deleted[$commit->id])));
}

/**
* Takes out the commits of anyone you muted or blocked since, as devnull
* leaves them out.
*/
private function forgetHidden(): void
{
$hidden = array_flip(app(ResponseCache::class)->takeHiddenAuthorIds());

if ($hidden === []) {
return;
}

$shown = fn (Commit $commit): bool => ! isset($hidden[$commit->author->id]);

$this->commits = array_values(array_filter($this->commits, $shown));
$this->latestCommits = array_values(array_filter($this->latestCommits, $shown));
}

/**
* Lays a first page of the log over what's showing. Rows already there change
* in place; newer ones wait for the pill, unless the log had nothing to show
Expand Down
Loading
Loading