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
47 changes: 46 additions & 1 deletion app/Devnull/Data/Conversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

namespace App\Devnull\Data;

use App\Devnull\Client;
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.
* you both share pulls, whether you stashed it, when you pinned it, and until
* when you muted it.
*/
final readonly class Conversation
{
Expand All @@ -26,6 +28,8 @@ public function __construct(
public int $ahead = 0,
public int $partnerReadThroughId = 0,
public bool $isArchived = false,
public ?CarbonImmutable $pinnedAt = null,
public ?CarbonImmutable $mutedUntilAt = null,
) {}

/**
Expand All @@ -52,14 +56,55 @@ public static function fromArray(array $data): self
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,
pinnedAt: Commit::date($data['pinned_at'] ?? null),
mutedUntilAt: Commit::date($data['muted_until_at'] ?? null),
);
}

/**
* The same row with some of it changed, as a write changes it before devnull answers.
*
* @param array<string, mixed> $changes
*/
public function with(array $changes): self
{
return new self(...[...get_object_vars($this), ...$changes]);
}

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

public function isPinned(): bool
{
return $this->pinnedAt !== null;
}

/**
* Muted until a moment still to come, as the web reads it.
*/
public function isMuted(): bool
{
return $this->mutedUntilAt !== null && $this->mutedUntilAt->isFuture();
}

/**
* What the web's menu and toasts call it: the partner's handle, or the org's title.
*/
public function menuTitle(?string $viewerId): string
{
return $this->isOrg() ? $this->title($viewerId) : ($this->partner?->handle() ?? '');
}

/**
* Where devnull keeps its thread, and takes its pin, mute and stash.
*/
public function threadPath(): string
{
return Client::threadPath($this->partner->username ?? '', $this->isOrg() ? $this->id : null);
}

/**
* The everyone else in an org, in the order they joined.
*
Expand Down
10 changes: 10 additions & 0 deletions app/Devnull/Data/Thread.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* `access` is the web's: open to push, a request of theirs to merge first,
* your own request waiting on them, or nothing at all because they don't
* follow you while requests are off (restricted) or have Remotes off (closed).
* You may have pinned it in your inbox, or muted it until some time.
*/
final readonly class Thread
{
Expand Down Expand Up @@ -48,6 +49,8 @@ public function __construct(
public ?string $orgName = null,
public array $members = [],
public ?string $role = null,
public ?CarbonImmutable $pinnedAt = null,
public ?CarbonImmutable $mutedUntilAt = null,
) {}

/**
Expand Down Expand Up @@ -98,11 +101,18 @@ public static function fromArray(array $json): self
orgName: is_string($org['name'] ?? null) && $org['name'] !== '' ? $org['name'] : null,
members: $members,
role: is_string($data['role'] ?? null) ? $data['role'] : null,
pinnedAt: Commit::date($data['pinned_at'] ?? null),
mutedUntilAt: Commit::date($data['muted_until_at'] ?? null),
);
}

public function isOrg(): bool
{
return $this->orgId !== null;
}

public function isMuted(): bool
{
return $this->mutedUntilAt !== null && $this->mutedUntilAt->isFuture();
}
}
49 changes: 49 additions & 0 deletions app/Devnull/MuteDuration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Devnull;

use Carbon\CarbonImmutable;

/**
* How long a Remotes conversation or org stays muted, as devnull takes it on
* `PUT .../mute` and the web's Mute dialog offers it. Off unmutes.
*
* until() works out what devnull will answer, so a row can show it muted
* before devnull has: forever is the far end of the calendar, and tomorrow is
* the coming midnight on the phone's clock, which devnull counts in the
* timezone it has for you. Its own answer takes over once it comes.
*/
enum MuteDuration: string
{
case Hour = 'hour';
case Tomorrow = 'tomorrow';
case Week = 'week';
case Forever = 'forever';
case Off = 'off';

/**
* The durations the web's dialog offers, in its order, with its words.
*
* @return array<string, string>
*/
public static function choices(): array
{
return [
self::Hour->value => 'For one hour',
self::Tomorrow->value => 'Until tomorrow',
self::Week->value => 'For one week',
self::Forever->value => 'Forever',
];
}

public function until(): ?CarbonImmutable
{
return match ($this) {
self::Hour => CarbonImmutable::now()->startOfSecond()->addHour(),
self::Tomorrow => CarbonImmutable::now()->addDay()->startOfDay()->utc(),
self::Week => CarbonImmutable::now()->startOfSecond()->addWeek(),
self::Forever => CarbonImmutable::parse('9999-12-31T00:00:00Z'),
self::Off => null,
};
}
}
61 changes: 61 additions & 0 deletions app/Devnull/RemoteMenu.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace App\Devnull;

use App\Devnull\Data\Conversation;
use App\Icons\Lucide;
use Native\Mobile\Edge\Elements\TopBarAction;
use Native\Mobile\Edge\Layouts\Builders\NavAction;

/**
* What a long press on a Remotes row opens, as the web's row menu has it, in
* its order, words and icons: View profile on a conversation with a person,
* Pin or Unpin, Stash or Pop, Mute or Unmute, then after a divider Block and
* their handle, or Leave org on an org, in red. Each item names the row, so a
* tap never rebuilds anything from an attribute (see Remotes).
*/
final class RemoteMenu
{
/**
* @return list<TopBarAction|NavAction>
*/
public static function items(Conversation $conversation, ?string $viewerId): array
{
$items = [];

if (! $conversation->isOrg()) {
$items[] = self::item($conversation, 'view-profile', 'View profile', Lucide::UserRound, 'viewProfile');
}

$items[] = $conversation->isPinned()
? self::item($conversation, 'unpin', 'Unpin', Lucide::PinOff, 'unpin')
: self::item($conversation, 'pin', 'Pin', Lucide::Pin, 'pin');

$items[] = $conversation->isArchived
? self::item($conversation, 'pop', 'Pop', Lucide::ArchiveRestore, 'pop')
: self::item($conversation, 'stash', 'Stash', Lucide::Archive, 'stash');

$items[] = $conversation->isMuted()
? self::item($conversation, 'unmute', 'Unmute', Lucide::Bell, 'unmute')
: self::item($conversation, 'mute', 'Mute', Lucide::BellOff, 'chooseMute');

$items[] = NavAction::divider();

$items[] = $conversation->isOrg()
? self::item($conversation, 'leave', 'Leave org', Lucide::LogOut, 'confirmLeave', destructive: true)
: self::item($conversation, 'block', 'Block '.$conversation->menuTitle($viewerId), Lucide::Ban, 'confirmBlock', destructive: true);

return $items;
}

private static function item(Conversation $conversation, string $action, string $label, Lucide $icon, string $method, bool $destructive = false): TopBarAction
{
return NavAction::make($action)
->label($label)
->icon(ios: $icon->ios(), android: $icon->android())
->destructive($destructive)
->press("{$method}('{$conversation->id}')")
->toElement()
->ref("{$action}-{$conversation->id}");
}
}
84 changes: 84 additions & 0 deletions app/Devnull/ResponseCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ final class ResponseCache
'#^remotes/orgs/[^/]+/messages$#',
];

/**
* The queries of the inbox's tabs, Open, Fetched and Stashed, as the Remotes screen asks for them.
*/
private const array InboxTabs = [[], ['tab' => 'fetched'], ['tab' => 'stashed']];

/**
* The kept answers that list people with whether you follow each one.
*/
Expand Down Expand Up @@ -568,6 +573,85 @@ public function blockChanged(array $json): void
$this->dropFromLogs($user['id']);
}

/**
* You pinned, unpinned, muted or unmuted a conversation or an org: every
* kept inbox tab with it takes the new fields, in devnull's words
* (`pinned_at`, `muted_until_at`), and sorts again as devnull does,
* pinned first.
*
* @param array<string, mixed> $fields
*/
public function conversationChanged(string $id, array $fields): void
{
foreach (self::InboxTabs as $query) {
$this->patch(Client::RemotesPath, $query, fn (array $json): array => self::inbox(
$json,
array_map(fn (array $row): array => ($row['id'] ?? null) === $id ? [...$row, ...$fields] : $row, self::rows($json)),
));
}
}

/**
* You stashed or popped a conversation or an org: it leaves the tab it
* was on and joins Stashed, or Open, in its place there.
*/
public function conversationStashed(string $id, bool $stashed): void
{
$row = null;

foreach (self::InboxTabs as $query) {
$row ??= collect(self::rows($this->get(Client::RemotesPath, $query) ?? []))->firstWhere('id', $id);
}

$this->conversationGone($id);

if ($row !== null) {
$this->patch(Client::RemotesPath, $stashed ? ['tab' => 'stashed'] : [], fn (array $json): array => self::inbox(
$json,
[...self::rows($json), [...$row, 'is_archived' => $stashed]],
));
}
}

/**
* You blocked the partner of a conversation, or left an org: it leaves every kept inbox tab.
*/
public function conversationGone(string $id): void
{
foreach (self::InboxTabs as $query) {
$this->patch(Client::RemotesPath, $query, fn (array $json): array => self::inbox(
$json,
array_values(array_filter(self::rows($json), fn (array $row): bool => ($row['id'] ?? null) !== $id)),
));
}
}

/**
* A kept inbox page's rows.
*
* @param array<string, mixed> $json
* @return list<array<string, mixed>>
*/
private static function rows(array $json): array
{
return array_values(array_filter(is_array($json['data'] ?? null) ? $json['data'] : [], is_array(...)));
}

/**
* A kept inbox page with these rows, in devnull's order: pinned first, then the latest message first.
*
* @param array<string, mixed> $json
* @param list<array<string, mixed>> $rows
* @return array<string, mixed>
*/
private static function inbox(array $json, array $rows): array
{
usort($rows, fn (array $a, array $b): int => [isset($b['pinned_at']), (string) ($b['last_message_at'] ?? ''), (string) ($b['id'] ?? '')]
<=> [isset($a['pinned_at']), (string) ($a['last_message_at'] ?? ''), (string) ($a['id'] ?? '')]);

return [...$json, 'data' => $rows];
}

/**
* The user a mute or block answered with, and the state devnull says it left.
*
Expand Down
9 changes: 7 additions & 2 deletions app/Icons/Lucide.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ enum Lucide
case GitMerge;
case Maximize;
case Key;
case PinOff;
case BellOff;
case LogOut;

/**
* The icon the web's inbox draws for a kind of notification.
Expand Down Expand Up @@ -187,6 +190,10 @@ public function custom(bool $filled = false): ?string
self::GitMerge => 'git-merge',
self::Maximize => 'maximize',
self::Key => 'key',
self::Pin => 'pin',
self::PinOff => 'pin-off',
self::BellOff => 'bell-off',
self::LogOut => 'log-out',
default => null,
};

Expand All @@ -201,7 +208,6 @@ public function ios(bool $filled = false): Ios|string
self::ChevronLeft => Ios::ChevronLeft,
self::ChevronDown => Ios::ChevronDown,
self::ArrowUpRight => Ios::ArrowUpRight,
self::Pin => Ios::Pin,
self::ListOrdered => Ios::ListNumber,
self::Building2 => Ios::Building2,
self::MapPin => Ios::Mappin,
Expand All @@ -219,7 +225,6 @@ public function android(bool $filled = false): AndroidSymbol|string
self::ChevronLeft => AndroidOutlined::ChevronLeft,
self::ChevronDown => AndroidOutlined::ExpandMore,
self::ArrowUpRight => AndroidOutlined::ArrowOutward,
self::Pin => AndroidOutlined::PushPin,
self::ListOrdered => AndroidOutlined::FormatListNumbered,
self::Building2 => AndroidOutlined::Business,
self::MapPin => AndroidOutlined::LocationOn,
Expand Down
Loading
Loading