Skip to content
Open
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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ These are the next things we want in the app. If you'd like to take one on, read
- A native image viewer, instead of opening images in a browser sheet
- Styling for livestreams
- Search
- A notifications centre that matches the web
- Who to follow
- A better welcome screen, with sign up and log in that don't mention GitHub, and maybe the public feed
- Everything Remotes does on the web, including audio recordings and image uploads
Expand Down
22 changes: 15 additions & 7 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -482,14 +482,19 @@ public function blocked(?string $cursor = null): Page

/**
* Your notifications grouped as the web groups them, filtered by the web's
* ?type= (all, replies, quotes, stars, mentions, follows or moderation).
* Listing any of them marks them all read.
* ?type= (all, none, one or more of replies, quotes, stars, mentions,
* follows and moderation comma-separated, or all). Listing any of them
* marks them all read.
*
* A poll passes $through, the oldest group still showing, so devnull sends
* everything since then on top of a page, as the web asks every 30s. It is
* left off otherwise, which asks for the same first page as ever.
*
* @return Page<Notification>
*/
public function notifications(string $type = 'all', ?string $cursor = null): Page
public function notifications(string $type = 'all', ?string $cursor = null, ?string $through = null): Page
{
return Page::fromArray($this->get('notifications', self::notificationsQuery($type, $cursor)), Notification::fromArray(...));
return Page::fromArray($this->get('notifications', self::notificationsQuery($type, $cursor, $through)), Notification::fromArray(...));
}

/**
Expand All @@ -503,13 +508,16 @@ public function notificationGroup(string $id, string $type = 'all', ?string $cur
}

/**
* All is the default, so it's left off, as the web's own links leave it off.
* All is the default, so it's left off, as the web's own links leave it
* off. What isn't sent is left off too: a cursor for the first page, and
* $through for every ask but a poll's, which keeps the first page it asks
* for the same one as ever.
*
* @return array<string, string|null>
*/
public static function notificationsQuery(string $type, ?string $cursor = null): array
public static function notificationsQuery(string $type, ?string $cursor = null, ?string $through = null): array
{
return ['type' => $type === 'all' ? null : $type, 'cursor' => $cursor];
return ['type' => $type === 'all' ? null : $type, 'cursor' => $cursor, 'through' => $through];
}

/**
Expand Down
94 changes: 91 additions & 3 deletions app/Devnull/Data/Notification.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
* first few people in the group, how many events and people there are and
* how many are unread. Expanded, a group lists its notifications one by one
* in the same shape. The commit's parent only comes along when you may see it.
*
* The whole commit comes along too, as the API sends it: its images beside a
* star on an image-only commit, and the commit of yours a quote quotes, which
* devnull only sends along when it loaded it — absent rather than null, so a
* quote with none of it sent shows no box at all, as on the web. A level-up
* tells of no level on the API, whose NotificationResource leaves the level,
* rank and its description out; they come along only when actually sent, as
* the web's own page has them, and none of them is made up.
*/
final readonly class Notification
{
Expand All @@ -33,6 +41,11 @@ public function __construct(
public string $groupId = '',
public int $eventCount = 1,
public ?bool $actorIsFollowed = null,
public ?Commit $commit = null,
public bool $quotedCommitSent = false,
public ?int $level = null,
public ?string $rank = null,
public ?string $rankDescription = null,
) {}

/**
Expand All @@ -50,6 +63,9 @@ public static function fromArray(array $data): self
? (int) $data['unread_count'] > 0
: array_key_exists('read_at', $data) && $data['read_at'] === null;
$id = (string) ($data['id'] ?? '');
$level = $data['level'] ?? null;
$rank = $data['rank'] ?? null;
$rankDescription = $data['rank_description'] ?? null;

return new self(
id: $id,
Expand All @@ -69,6 +85,11 @@ public static function fromArray(array $data): self
groupId: is_string($data['group_id'] ?? null) && $data['group_id'] !== '' ? $data['group_id'] : $id,
eventCount: max(1, (int) ($data['event_count'] ?? 1)),
actorIsFollowed: is_bool($actorJson['is_followed'] ?? null) ? $actorJson['is_followed'] : null,
commit: $commit === [] ? null : Commit::fromArray($commit),
quotedCommitSent: array_key_exists('quoted_commit', $commit),
level: is_numeric($level) ? (int) $level : null,
rank: is_string($rank) && trim($rank) !== '' ? $rank : null,
rankDescription: is_string($rankDescription) && trim($rankDescription) !== '' ? $rankDescription : null,
);
}

Expand All @@ -92,6 +113,16 @@ public function isFollow(): bool
return $this->type === 'followed';
}

/**
* You reached a level, which the web words without naming you as its
* actor, and opens on your profile. Its level and rank only came along
* when devnull sent them, which its signed API does not.
*/
public function isLevelUp(): bool
{
return $this->type === 'leveled_up';
}

/**
* A moderator approved or rejected your commit. Nobody else is named.
*/
Expand All @@ -113,6 +144,57 @@ public function isAboutYourCommit(): bool
return $this->isStar() || $this->isModeration();
}

/**
* The commit is the actor's own words: a reply, a quote or a mention, so
* the web writes their name into the sentence and shows it as their commit.
*/
public function isActorsCommit(): bool
{
return in_array($this->type, ['replied', 'quoted', 'mentioned'], true);
}

/**
* The images of your commit a star came with, as the web shows them beside
* it: only when devnull loaded them, which its signed API does not today.
*
* @return list<CommitImage>
*/
public function starredImages(): array
{
return $this->isStar() && $this->commit !== null ? $this->commit->images : [];
}

/**
* A star on your commit that says nothing but its images, as the web words
* it "starred your image" and shows nothing of the commit beside them.
* The body it reads is the one the row shows, so a commit whose segments
* weren't sent still counts as readable, worded as it reads.
*/
public function isImageOnlyStar(): bool
{
return $this->starredImages() !== [] && trim($this->body ?? '') === '';
}

/**
* Whether the commit of yours this one quotes came along at all, as the
* web shows the box under a quote only when it did, which its signed API
* does not load today.
*/
public function showsQuotedCommit(): bool
{
return $this->type === 'quoted' && $this->quotedCommitSent;
}

/**
* The commit of yours a quote quotes: a tombstone once it was deleted, or
* null once you may no longer see it, either of which the web still boxes,
* in the web's words.
*/
public function quotedOwnCommit(): ?Commit
{
return $this->showsQuotedCommit() ? $this->commit?->quotedCommit : null;
}

/**
* Whether the group has more than one event, so it opens to list them, as on the web.
*/
Expand All @@ -135,11 +217,12 @@ public function isNewSince(?CarbonImmutable $readAt): bool
}

/**
* The commit a tap opens, as on the web: the notification's own commit. A follow opens a profile instead.
* The commit a tap opens, as on the web: the notification's own commit. A
* follow, or a level-up, opens a profile instead.
*/
public function commitToOpen(): ?string
{
return $this->isFollow() ? null : $this->commitId;
return $this->isFollow() || $this->isLevelUp() ? null : $this->commitId;
}

/**
Expand Down Expand Up @@ -174,15 +257,20 @@ public function others(): string

/**
* What happened, as the web words it. For a person it follows their name and their handle or the others.
* A star on an image-only commit says it was your image, or images. A
* level-up says nothing of which level, since the API tells it not.
*/
public function sentence(): string
{
return match ($this->type) {
'followed' => 'followed you',
'mentioned' => 'mentioned you',
'starred' => 'starred your commit',
'starred' => $this->isImageOnlyStar()
? 'starred your '.(count($this->starredImages()) === 1 ? 'image' : 'images')
: 'starred your commit',
'quoted' => 'quoted your commit',
'approved', 'rejected' => "Your commit was {$this->type}",
'leveled_up' => 'You reached a new level',
default => 'replied to your commit',
};
}
Expand Down
126 changes: 126 additions & 0 deletions app/Devnull/NotificationFilters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace App\Devnull;

use Illuminate\Support\Facades\Cache;

/**
* The notification categories the web's filter row keeps on, as the web keeps
* them: a selection of its six categories, remembered per account. Nothing
* selected asks for none of them, all six ask for all, and anything between
* is sent as the comma-separated ?type= the API takes.
*
* An app that kept one filter at a time, on the Notifications tab (see
* ShownTabs), opens on that one category, or on all of them when it kept All,
* until a new selection is remembered here.
*/
final class NotificationFilters
{
/**
* The web's notification categories, in the order its filter row runs them.
*/
public const array Categories = ['replies', 'quotes', 'stars', 'mentions', 'follows', 'moderation'];

/**
* The categories kept on for the account, in the web's order: all of them
* when nothing was kept, or what the single filter an older app kept on
* was, which All opens on all of them.
*
* @return list<string>
*/
public function enabled(?string $userId): array
{
$kept = $userId === null ? null : Cache::get($this->key($userId));

if (is_array($kept) && array_is_list($kept) && self::areCategories($kept)) {
return self::normalize($kept);
}

$filter = app(ShownTabs::class)->get($userId, 'notifications', ['all', ...self::Categories], 'all');

return $filter === 'all' ? self::Categories : [$filter];
}

/**
* The ?type= the selection asks for: all, none, or the categories the web
* sends them as, comma-separated in its order.
*/
public function type(?string $userId): string
{
return self::typeOf($this->enabled($userId));
}

/**
* Keeps the selection on for the account, dropping anything that isn't one
* of the web's categories, so nothing else is ever kept or sent.
*
* @param list<string> $categories
*/
public function remember(?string $userId, array $categories): void
{
if ($userId !== null) {
Cache::forever($this->key($userId), self::normalize($categories));
}
}

/**
* The ?type= a selection asks for, as the web sends it: all six is all,
* none is none, and anything between is the categories comma-separated in
* the web's order.
*
* @param list<string> $categories
*/
public static function typeOf(array $categories): string
{
$enabled = self::normalize($categories);

return match (count($enabled)) {
0 => 'none',
count(self::Categories) => 'all',
default => implode(',', $enabled),
};
}

/**
* A selection as the web's order runs it, without repeats and without
* anything that isn't one of its categories. The ?type= a selection was
* sent as reads back as the selection it asks for: all is every one of
* them, none is not one.
*
* @param array<string>|string $categories the categories, or the comma-separated ?type= they were sent as
* @return list<string>
*/
public static function normalize(array|string $categories): array
{
$sent = match ($categories) {
'all' => self::Categories,
'none' => [],
default => is_string($categories) ? explode(',', $categories) : $categories,
};

return array_values(array_intersect(self::Categories, array_unique($sent)));
}

/**
* Whether every one of these is one of the web's categories: a string
* each, and nothing else, so nothing else kept in the cache is ever taken
* for a selection.
*
* @param list<string> $categories
*/
private static function areCategories(array $categories): bool
{
foreach ($categories as $category) {
if (! is_string($category) || ! in_array($category, self::Categories, true)) {
return false;
}
}

return true;
}

private function key(string $userId): string
{
return "devnull.notification-filters.{$userId}";
}
}
3 changes: 3 additions & 0 deletions app/Icons/Lucide.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ enum Lucide
case UserPlus;
case UserCheck;
case AtSign;
case ChevronsUp;
case FoldVertical;
case Award;
case Ellipsis;
Expand Down Expand Up @@ -93,6 +94,7 @@ public static function forNotification(string $type): self
{
return match ($type) {
'followed' => self::UserPlus,
'leveled_up' => self::ChevronsUp,
'quoted' => self::Quote,
'starred' => self::Star,
'mentioned' => self::AtSign,
Expand Down Expand Up @@ -146,6 +148,7 @@ public function custom(bool $filled = false): ?string
self::UserPlus => 'user-plus',
self::UserCheck => 'user-check',
self::AtSign => 'at-sign',
self::ChevronsUp => 'chevrons-up',
self::FoldVertical => 'fold-vertical',
self::Award => 'award',
self::Ellipsis => 'ellipsis',
Expand Down
8 changes: 8 additions & 0 deletions app/NativeComponents/Concerns/OpensInTab.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
*/
trait OpensInTab
{
/**
* Resume the existing feed instead of mounting another one over it.
*/
public function returnToLog(): void
{
$this->popTo(TabsLayout::Tabs[0]);
}

/**
* @param array<string, mixed> $data
*/
Expand Down
Loading
Loading