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
118 changes: 109 additions & 9 deletions app/NativeComponents/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\NativeComponents;

use App\Devnull\Data\Commit;
use App\Devnull\Data\Page;
use App\Devnull\Data\Profile as ProfileData;
use App\Devnull\Data\User;
use App\Devnull\ErrorCode;
Expand Down Expand Up @@ -47,6 +48,12 @@
* It opens on the profile as it was last time, or opened from a commit, on
* what the commit knew of its author, then asks devnull again behind it. The
* header changes in place; newer commits wait in the pill (see HoldsNewCommits).
*
* Commits and Replies are pages you swipe between, as on the Log (see Log),
* under a header that scrolls up with the page you're on until the tabs pin
* under the nav bar. Only the tab you're on is live; the other shows the first
* few commits kept for it. Each has its own list, and the one you leave starts
* again at the top of a fresh one.
*/
class Profile extends NativeComponent
{
Expand All @@ -60,10 +67,27 @@ class Profile extends NativeComponent
use ShowsCommits;
use TalksToDevnull;

/**
* Every frame goes in full, as on the Log (see Log::$forceFullFrames). A tab
* tapped again goes back to the top in a list with a new key and the same
* rows, which EDGE's subtree reuse would otherwise never send.
*/
protected bool $forceFullFrames = true;

public string $username = '';

public const array Tabs = ['commits', 'replies'];

/**
* The web's tab labels.
*/
public const array Labels = ['commits' => 'Commits', 'replies' => 'Replies'];

/**
* How many kept commits the tab you're not on shows, enough to fill the screen while you swipe to it.
*/
public const int PreviewCommits = 5;

/**
* The alerts the safety menu confirms with, as the web words them. Each one's id names the profile too, so an
* answer only reaches the profile that asked.
Expand All @@ -86,6 +110,13 @@ class Profile extends NativeComponent

public string $tab = 'commits';

/**
* The list key of the tab you're not on. The tab you're on uses $listKey.
*
* @var array<string, int>
*/
public array $listKeys = [];

/**
* Whether the tab has had commits to show, kept or fresh. Until then it shows skeleton rows.
*/
Expand Down Expand Up @@ -183,18 +214,41 @@ public function load(): void
$this->refreshProfile(showNewer: true);
}

public function showTab(string $tab): void
/**
* The pager settling on a tab, after a swipe or a tab tap. The tab it's already on changes nothing.
*/
public function showPage(int $index): void
{
if (! in_array($tab, self::Tabs, true) || $tab === $this->tab) {
return;
$tab = self::Tabs[$index] ?? null;

if ($tab !== null && $tab !== $this->tab) {
$this->switchTo($tab);
}
}

$this->tab = $tab;
/**
* A tap on the tab you're on, which the pager sends instead of moving: back to the top of it.
*/
public function backToTop(int $index): void
{
if ((self::Tabs[$index] ?? null) === $this->tab) {
$this->listKey++;
}
}

/**
* The tab you go to keeps the list its first few commits were drawn in,
* and fills it. The tab you leave starts again at the top of a fresh one.
*/
private function switchTo(string $tab): void
{
if ($this->isMe()) {
app(ShownTabs::class)->remember($this->me()?->id, 'profile', $tab);
}

$this->listKeys[$this->tab] = $this->listKey + 1;
$this->tab = $tab;
$this->listKey = $this->listKeys[$tab] ?? 0;
$this->commits = [];
$this->threads = [];
$this->nextCursor = null;
Expand Down Expand Up @@ -649,23 +703,69 @@ private function commitsPath(): string
/**
* @return array<string, string|null>
*/
private function commitsQuery(): array
private function commitsQuery(?string $tab = null): array
{
return ['replies' => $this->tab === 'replies' ? '1' : null];
return ['replies' => ($tab ?? $this->tab) === 'replies' ? '1' : null];
}

public function render(): View
{
$pinnedCommit = $this->pinnedCommit();
$pages = array_map(fn (string $tab): array => $tab === $this->tab
? $this->page($tab, $this->commits, $this->nextCursor, $this->commitsLoaded, $this->listKey)
: $this->previewPage($tab), self::Tabs);

return view('native.profile', [
'myColour' => $this->myColour(),
'isBlocked' => $this->isBlocking(),
'isMe' => $this->isMe(),
'newCommitsLabel' => $this->newCommitsLabel(),
'viewer' => $this->me(),
'pinnedCommit' => $pinnedCommit,
'rows' => Timeline::feed($this->commits),
'pages' => $pages,
'pageIndex' => array_search($this->tab, self::Tabs, true),
'tabs' => array_values(self::Labels),
]);
}

/**
* The tab you're not on: the first few commits kept for it, as it will
* open, or skeleton rows with nothing kept.
*
* @return array<string, mixed>
*/
private function previewPage(string $tab): array
{
$json = $this->cached($this->commitsPath(), $this->commitsQuery($tab));
$kept = is_array($json['data'] ?? null) ? $json['data'] : [];
$page = $json === null ? null : Page::fromArray(['data' => array_slice($kept, 0, self::PreviewCommits)] + $json, Commit::fromArray(...));

// Rows past the preview are left out, with a skeleton row for them, as for the next page.
$nextCursor = count($kept) > self::PreviewCommits ? 'preview' : $page?->nextCursor;

return $this->page($tab, $page->items ?? [], $nextCursor, $page !== null, $this->listKeys[$tab] ?? 0);
}

/**
* What a tab's page shows. The web leaves the pinned commit out of the replies tab.
*
* @param list<Commit> $commits
* @return array<string, mixed>
*/
private function page(string $tab, array $commits, ?string $nextCursor, bool $loaded, int $listKey): array
{
$showing = $tab === $this->tab;

return [
'tab' => $tab,
'showing' => $showing,
'listKey' => $listKey,
'loaded' => $loaded,
'error' => $showing ? $this->error : null,
'pinnedCommit' => $tab === 'commits' ? $this->profile?->pinnedCommit : null,
'isEmpty' => $commits === [],
'nextCursor' => $nextCursor,
'rows' => Timeline::feed($commits),
// Replies the tab you're on has opened, under their commits.
'threads' => $showing ? $this->threads : [],
];
}
}
37 changes: 27 additions & 10 deletions resources/views/native/partials/new-commits-pill.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,42 @@
DESIGN.md's new commits pill: Commit Blue, "↑ 3 new commits". The commits are already fetched, so a tap puts them on
top with nothing to wait for.

As on the web, it sits in a strip of its own between the top of the list (the Log's composer, a profile's tabs) and
the commits, and pins to the top once you scroll past it. EDGE tells PHP nothing about where a list is scrolled, so
the list pins it itself, as a sticky header.
As on the web, it sits in a strip of its own between the top of the list (the Log's composer) and the commits, and
pins to the top once you scroll past it. EDGE tells PHP nothing about where a list is scrolled, so the list pins it
itself, as a sticky header.

Its header is always in the list, hidden until there's something to show ($shown), so the list keeps its shape when
new commits come in. A node added beside a scrolled list sometimes left the list blank on iOS: its rows still in the
tree and taking taps, nothing drawn.

A profile's pager has its tabs above the list, so there it goes in the pager's overlay instead ($hanging), which
hangs it under the tabs wherever the header has scrolled them, over the commits.
--}}
@php($shown ??= true)
<native:sticky-header ref="new-commits-slot" class="w-full bg-theme-surface {{ $shown ? '' : 'hidden' }}">
@if ($shown)
<native:row class="w-full py-3 justify-center">
@php($hanging ??= false)
@if ($hanging)
<native:row ref="new-commits-slot" class="w-full pt-3 justify-center {{ $shown ? '' : 'hidden' }}">
@if ($shown)
<native:pressable ref="new-commits" @press="{{ $press }}" class="rounded-full bg-theme-primary shadow-md" a11y-label="Show {{ $label }}">
<native:row class="px-4 py-[6] gap-[6] items-center">
<native:icon :ios="\App\Icons\Lucide::ArrowUp->ios()" :android="\App\Icons\Lucide::ArrowUp->android()" :size="16" class="text-theme-on-primary" />
<native:text font="medium" class="text-sm text-theme-on-primary">{{ $label }}</native:text>
</native:row>
</native:pressable>
</native:row>
<native:column class="w-full h-[1] bg-theme-hairline" />
@endif
</native:sticky-header>
@endif
</native:row>
@else
<native:sticky-header ref="new-commits-slot" class="w-full bg-theme-surface {{ $shown ? '' : 'hidden' }}">
@if ($shown)
<native:row class="w-full py-3 justify-center">
<native:pressable ref="new-commits" @press="{{ $press }}" class="rounded-full bg-theme-primary shadow-md" a11y-label="Show {{ $label }}">
<native:row class="px-4 py-[6] gap-[6] items-center">
<native:icon :ios="\App\Icons\Lucide::ArrowUp->ios()" :android="\App\Icons\Lucide::ArrowUp->android()" :size="16" class="text-theme-on-primary" />
<native:text font="medium" class="text-sm text-theme-on-primary">{{ $label }}</native:text>
</native:row>
</native:pressable>
</native:row>
<native:column class="w-full h-[1] bg-theme-hairline" />
@endif
</native:sticky-header>
@endif
14 changes: 0 additions & 14 deletions resources/views/native/partials/page-tabs.blade.php

This file was deleted.

108 changes: 108 additions & 0 deletions resources/views/native/partials/profile-header.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
{{--
The profile's header, which the pager draws above its tabs (see profile.blade.php): the avatar ringed in their
lane, Follow or Edit on GitHub, who they are, what GitHub says about them, their counts, then their activity,
streaks and achievements. It scrolls up with the tab you're on until the tabs pin under the nav bar.
--}}
<native:column class="w-full bg-theme-surface">
<native:column ref="profile-header" class="w-full px-4 pt-6 pb-4 gap-4">
<native:row class="w-full items-start justify-between">
{{-- The avatar wears the author's lane as a ring, offset from the surface. --}}
<native:stack :width="92" :height="92" class="rounded-full bg-theme-lane-{{ $user->lane() }}">
<native:stack :width="88" :height="88" class="rounded-full bg-theme-surface">
@include('native.partials.avatar', ['user' => $user, 'size' => 80])
</native:stack>
</native:stack>

@if ($sketched)
{{-- Follow waits for devnull to say whether you already do. --}}
@elseif ($isMe)
<native:button ref="edit-on-github" variant="secondary" size="sm" :ios-icon-trailing="\App\Icons\Lucide::ArrowUpRight->ios()" :android-icon-trailing="\App\Icons\Lucide::ArrowUpRight->android()" @press="editOnGitHub">Edit on GitHub</native:button>
@elseif ($profile->isFollowing)
<native:button ref="follow" variant="secondary" size="sm" @press="toggleFollow" a11y-hint="Unfollows {{ $user->handle() }}">Unfollow</native:button>
@else
<native:button ref="follow" :variant="$myColour" size="sm" @press="toggleFollow">Follow</native:button>
@endif
</native:row>

<native:column class="w-full gap-1">
<native:text font="semibold" class="text-2xl tracking-tight text-theme-ink">{{ $user->name }}</native:text>
{{--
The handle, then what the web marks beside it: Follows you, Muted, and the Unix rank in the
owner's lane. EDGE's rows don't wrap on iOS, so the handle gives way first.
--}}
<native:row ref="profile-handle" class="w-full gap-2 items-center">
<native:column :width="8" :height="8" class="rounded-full bg-theme-lane-{{ $user->lane() }} flex-shrink-0" />
<native:text font="mono" class="text-[13px] text-theme-ink-muted flex-shrink" :max-lines="1">{{ $user->handle() }}</native:text>
@if ($profile->followsYou && ! $sketched)
<native:column class="px-2 py-[2] rounded bg-theme-wash flex-shrink-0">
<native:text class="text-xs text-theme-ink-muted">Follows you</native:text>
</native:column>
@endif
@if ($profile->isMuting && ! $sketched)
<native:column ref="muted" class="px-2 py-[2] rounded bg-theme-wash flex-shrink-0">
<native:text class="text-xs text-theme-ink-muted">Muted</native:text>
</native:column>
@endif
@if ($profile->achievements && ! $sketched)
<native:column ref="rank" class="px-2 py-[2] rounded-full border border-theme-lane-{{ $user->lane() }}/25 bg-theme-lane-{{ $user->lane() }}/10 flex-shrink-0" a11y-label="Level {{ $profile->achievements->level }}, {{ $profile->achievements->rank }}">
<native:text font="mono-medium" class="text-[11px] text-theme-lane-{{ $user->lane() }}">{{ $profile->achievements->label() }}</native:text>
</native:column>
@endif
</native:row>
</native:column>

@if ($user->bio)
<native:text class="w-full text-[15px] leading-relaxed text-theme-ink">{{ $user->bio }}</native:text>
@endif

{{--
What GitHub says about them, as the web lists it under the bio, links in Commit Blue. The web wraps
them onto as many lines as it needs; EDGE's rows don't wrap on iOS, so here each has its own line.
--}}
<native:column ref="profile-facts" class="w-full gap-[6]">
@if ($user->company)
@include('native.partials.profile-fact', ['key' => 'company', 'icon' => \App\Icons\Lucide::Building2, 'label' => $user->company])
@endif
@if ($user->location)
@include('native.partials.profile-fact', ['key' => 'location', 'icon' => \App\Icons\Lucide::MapPin, 'label' => $user->location])
@endif
@if ($user->websiteLabel())
@include('native.partials.profile-fact', ['key' => 'website', 'icon' => \App\Icons\Lucide::Link, 'label' => $user->websiteLabel(), 'press' => 'openWebsite'])
@endif
@include('native.partials.profile-fact', ['key' => 'github', 'icon' => \App\Icons\Lucide::Github, 'label' => 'github.com/'.$user->username, 'press' => 'openGitHubProfile'])
@if ($user->createdAt)
@include('native.partials.profile-fact', ['key' => 'joined', 'icon' => \App\Icons\Lucide::CalendarDays, 'label' => 'Joined '.$user->createdAt->format('F Y')])
@endif
</native:column>

@if (! $sketched)
{{-- Following and followers open their lists, as the web links them. --}}
<native:row ref="counts" class="w-full gap-4">
@foreach ([[$profile->commitsCount, 'commit', 'commits', null], [$profile->followingCount, 'following', 'following', 'following'], [$profile->followersCount, 'follower', 'followers', 'followers']] as [$count, $singular, $plural, $list])
@if ($list === null)
<native:row class="gap-1 items-center">
<native:text font="mono-medium" class="text-[13px] text-theme-ink">{{ $count }}</native:text>
<native:text class="text-sm text-theme-ink-muted">{{ $count === 1 ? $singular : $plural }}</native:text>
</native:row>
@else
<native:pressable ref="count-{{ $list }}" @press="openConnections('{{ $list }}')" a11y-label="{{ $count }} {{ $count === 1 ? $singular : $plural }}">
<native:row class="gap-1 items-center">
<native:text font="mono-medium" class="text-[13px] text-theme-ink">{{ $count }}</native:text>
<native:text class="text-sm text-theme-ink-muted">{{ $count === 1 ? $singular : $plural }}</native:text>
</native:row>
</native:pressable>
@endif
@endforeach
</native:row>
@endif
</native:column>
<native:column class="w-full h-[1] bg-theme-hairline" />

@if (! $sketched)
@include('native.partials.activity', ['activity' => $profile->activity, 'lane' => $user->lane()])
@include('native.partials.streaks', ['streaks' => $profile->streaks, 'lane' => $user->lane()])
@if ($profile->achievements)
@include('native.partials.achievements', ['achievements' => $profile->achievements, 'lane' => $user->lane()])
@endif
@endif
</native:column>
Loading
Loading