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
37 changes: 37 additions & 0 deletions app/NativeComponents/Concerns/InteractsWithDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ trait InteractsWithDiscovery
/** Whether the discovered-servers bottom sheet is open on this screen. */
public bool $showServers = false;

/**
* Whether the full-screen "connecting…" cover is up.
*
* It exists for more than politeness: the remote app pushes its OWN theme
* (colors, typography) as it boots, which lands while Jump's UI is still on
* screen. Its font aliases name files that don't exist in Jump's bundle, so
* home visibly re-renders in fallback fonts for the second or two before
* the remote tree takes over. Covering that window hides the shift — and
* the cover itself is styled with fixed colors and no font tokens, so the
* incoming theme can't move it either.
*/
public bool $connecting = false;

/** Advertised name of the server being connected to, for the cover's copy. */
public string $connectingTo = '';

/**
* Start browsing the LAN. Call from each tab's `mount()` — NOT from a
* service provider: the native browser reports already-running servers in
Expand Down Expand Up @@ -53,6 +69,12 @@ public function startDiscovery(): void
public function jumpSessionResumed(): void
{
$this->showServers = false;
// Covers both exits: a clean 3-finger swipe, and a connection that
// died or never produced a tree (the relay routes an abandoned
// reconnect through the same escape-hatch path). Either way home is
// about to repaint, so the cover must not survive into it.
$this->connecting = false;
$this->connectingTo = '';
app(DiscoveredServers::class)->flush();
Discovery::stop();
Discovery::start();
Expand Down Expand Up @@ -126,9 +148,22 @@ public function connect(string $host, string $port): void
return;
}

$this->raiseConnectingCover($host, $port);

Discovery::connect($host, $port);
}

/**
* Raise the cover BEFORE the bridge call. Discovery::connect() returns
* immediately (the native side dials on the main queue), so this render
* lands first and the remote tree replaces it whenever it's ready.
*/
private function raiseConnectingCover(string $host, string $port): void
{
$this->connectingTo = app(DiscoveredServers::class)->nameFor($host, $port);
$this->connecting = true;
}

/** The coaching sheet's "don't show this again" checkbox. */
public function setDontShowExitHintAgain(bool $value): void
{
Expand All @@ -147,6 +182,8 @@ public function connectPending(): void
$this->showExitHint = false;

if ($this->pendingHost !== '') {
$this->raiseConnectingCover($this->pendingHost, $this->pendingPort);

Discovery::connect($this->pendingHost, $this->pendingPort);
}
}
Expand Down
36 changes: 30 additions & 6 deletions app/NativeComponents/Docs.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Fluent;
use Illuminate\View\View;
use Native\Mobile\Attributes\Lazy;
use Native\Mobile\Edge\Element;
use Native\Mobile\Edge\Layouts\Builders\NavAction;
use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Edge\NativeElementCollector;
Expand All @@ -17,7 +19,10 @@
/**
* Docs tab — the NativePHP mobile docs, fetched from the public MCP navigation
* API (full page content inline), cached 24h. Collapsible section TOC → page.
* #[Lazy] paints the skeleton while a cold/stale cache is fetched; a fresh
* cache paints the real TOC straight away (see publishPlaceholder()).
*/
#[Lazy]
class Docs extends NativeComponent
{
use InteractsWithDiscovery;
Expand All @@ -41,8 +46,6 @@ class Docs extends NativeComponent

public ?array $page = null;

public bool $loading = true;

public bool $failed = false;

/**
Expand All @@ -60,17 +63,38 @@ public function navTitle(): string
return 'Docs';
}

protected function placeholder(): Element|View
{
return view('native.docs-skeleton');
}

/**
* Skip the skeleton when the corpus is already cached and fresh — mount()
* then paints the real TOC in the same frame, so a skeleton would just be
* a one-frame flash. Only a cold or stale cache pays the network-first
* fetch that the skeleton exists to cover.
*/
public function publishPlaceholder(): void
{
if (DocsIndex::fresh() !== null) {
return;
}

parent::publishPlaceholder();
}

public function mount(): void
{
$this->startDiscovery();

// Fresh cache → paint from it (publishPlaceholder() skipped the
// skeleton on the same condition); otherwise fetch network-first.
try {
$this->sections = DocsIndex::sections();
$this->failed = empty($this->sections);
$this->sections = DocsIndex::fresh() ?? DocsIndex::sections();
} catch (\Throwable) {
$this->failed = true;
// Leave sections empty — the failed flag below covers it.
}
$this->loading = false;
$this->failed = empty($this->sections);

// Deep link (https://nativephp.com/docs/mobile/{v}/{section}/{page}) —
// land directly on the linked page. API page ids mirror the website
Expand Down
41 changes: 31 additions & 10 deletions app/NativeComponents/Home.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Native\Mobile\Attributes\On;
use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Events\Scanner\CodeScanned;
use Native\Mobile\Events\System\AppearanceChanged;
use Native\Mobile\Facades\Browser;
use Native\Mobile\Facades\Scanner;

Expand Down Expand Up @@ -101,36 +102,56 @@ public function openPartner(string $url): void

/**
* Vetted NativePHP consulting partners, mirrored from
* nativephp.com/consulting. `logo` is a remote raster URL where the partner
* publishes one (the native <image> renderer needs http(s) raster, not the
* site's bundled SVG); null falls back to a lettered monogram tile.
* nativephp.com/consulting. Logos are transparent PNGs bundled under
* public/img/partners (rasterised from the site's SVGs — the native
* <image> renderer can't draw SVG), with a `-dark` variant swapped in via
* isDark(). width/height preserve each PNG's aspect ratio at a display
* size balanced across the set.
*
* @return list<array{name: string, tagline: string, url: string, logo: ?string}>
* @return list<array{name: string, url: string, logo: string, width: int, height: int}>
*/
protected function partners(): array
{
return [
[
'name' => 'Nexcalia',
'tagline' => 'Smart tools for scheduling & visitor management.',
'url' => 'https://www.nexcalia.com/?ref=nativephp',
'logo' => null,
'logo' => $this->partnerLogo('nexcalia'),
'width' => 128,
'height' => 40,
],
[
'name' => 'Web Mavens',
'tagline' => 'Laravel Partners crafting secure, SOC 2-ready apps.',
'url' => 'https://www.webmavens.com/?ref=nativephp',
'logo' => null,
'logo' => $this->partnerLogo('webmavens'),
'width' => 208,
'height' => 28,
],
[
'name' => 'Synergi Tech',
'tagline' => 'Bespoke software for complex infrastructure.',
'url' => 'https://synergitech.co.uk/partners/nativephp/',
'logo' => 'https://synergitech.co.uk/logo.png',
'logo' => $this->partnerLogo('synergi'),
'width' => 104,
'height' => 48,
],
];
}

protected function partnerLogo(string $slug): string
{
return public_path('img/partners/'.$slug.(isDark() ? '-dark' : '').'.png');
}

/**
* The partner logos are appearance-specific rasters picked server-side,
* so a light/dark flip must re-render to swap them.
*/
#[On(AppearanceChanged::class)]
public function appearanceChanged(string $mode): void
{
// Re-render only.
}

public function render(): View
{
return view('native.home', [
Expand Down
9 changes: 8 additions & 1 deletion app/NativeComponents/Layouts/JumpTabsLayout.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,14 @@ public function floatingOverlay(NativeComponent $screen): ?FloatingOverlay
// must render (pill hidden) whenever the active screen has it open.
$exitHintOpen = (bool) ($screen->showExitHint ?? false);

if ($store->isEmpty() && ! $exitHintOpen) {
// Same for the connecting cover, and it matters more: the server can
// drop out of the store mid-connect (its ServerLost fires while we're
// dialling), which would empty the store and take the cover down with
// the overlay — leaving Jump's UI exposed for exactly the window the
// cover exists to hide.
$connecting = (bool) ($screen->connecting ?? false);

if ($store->isEmpty() && ! $exitHintOpen && ! $connecting) {
return null;
}

Expand Down
34 changes: 28 additions & 6 deletions app/NativeComponents/Videos.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,51 @@
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Native\Mobile\Attributes\Lazy;
use Native\Mobile\Edge\Element;
use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Facades\Browser;

/**
* Videos tab — the NativePHP YouTube channel feed (RSS), fetched + parsed
* server-side and cached 6h. Featured card + list, matching the Jump app.
* #[Lazy] paints the skeleton while a cold cache is fetched; a warm cache
* paints the real feed straight away (see publishPlaceholder()).
*/
#[Lazy]
class Videos extends NativeComponent
{
use InteractsWithDiscovery;
use SearchesDocs;

private const FEED = 'https://www.youtube.com/feeds/videos.xml?channel_id=UCbkAE6vLlR6lOy_nxd--22g';

private const CACHE_KEY = 'jump.videos';

/** @var array<int,array<string,string>> */
public array $videos = [];

public bool $loading = true;

public bool $failed = false;

protected function placeholder(): Element|View
{
return view('native.videos-skeleton');
}

/**
* Skip the skeleton when the feed is already cached — load() returns from
* cache within the frame, so a skeleton would just be a one-frame flash.
* Only a cold cache pays the fetch that the skeleton exists to cover.
*/
public function publishPlaceholder(): void
{
if (Cache::has(self::CACHE_KEY)) {
return;
}

parent::publishPlaceholder();
}

public function navTitle(): string
{
return 'Videos';
Expand All @@ -42,21 +66,19 @@ public function mount(): void

public function reload(): void
{
Cache::forget('jump.videos');
$this->loading = true;
Cache::forget(self::CACHE_KEY);
$this->failed = false;
$this->load();
}

private function load(): void
{
try {
$this->videos = Cache::remember('jump.videos', now()->addHours(6), fn () => $this->fetch());
$this->videos = Cache::remember(self::CACHE_KEY, now()->addHours(6), fn () => $this->fetch());
$this->failed = empty($this->videos);
} catch (\Throwable) {
$this->failed = true;
}
$this->loading = false;
}

/** @return array<int,array<string,string>> */
Expand Down
9 changes: 9 additions & 0 deletions app/Support/DiscoveredServers.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ public function all(): array
return array_values($this->servers);
}

/**
* Advertised name for a connection, or '' when it isn't (or is no longer)
* in the store — a QR scan connects to a host:port that was never browsed.
*/
public function nameFor(string $host, string $port): string
{
return $this->servers[$host.':'.$port]['name'] ?? '';
}

public function count(): int
{
return count($this->servers);
Expand Down
43 changes: 40 additions & 3 deletions app/Support/DocsIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
*/
class DocsIndex
{
private const CACHE_KEY = 'jump.docs';

/**
* Marker key whose mere presence means the cached corpus was fetched
* recently enough to trust without going to the network. Separate from
* the corpus key so the corpus itself survives as an offline fallback
* long after it stops counting as fresh.
*/
private const FRESH_KEY = 'jump.docs.fresh';

/** Minutes a fetched corpus is served straight from cache. */
private const FRESH_MINUTES = 30;

private const SECTION_NAMES = [
'getting-started' => 'Getting Started',
'the-basics' => 'The Basics',
Expand All @@ -41,15 +54,39 @@ public static function sections(): array
try {
$fresh = static::fetch();
if (! empty($fresh)) {
Cache::put('jump.docs', $fresh, now()->addHours(24));
Cache::put(self::CACHE_KEY, $fresh, now()->addHours(24));
Cache::put(self::FRESH_KEY, true, now()->addMinutes(self::FRESH_MINUTES));

return $fresh;
}
} catch (\Throwable) {
// fall through to the cached copy
}

return Cache::get('jump.docs', []);
return Cache::get(self::CACHE_KEY, []);
}

/**
* The cached corpus when it's recent enough to paint immediately, else
* null. Lets a screen skip both the network-first fetch above AND its
* loading skeleton — with a warm cache there's nothing to wait for, so
* the skeleton would only flash for a frame.
*
* The window is deliberately short: past it, sections() refetches (and
* the screen shows its skeleton) so a docs update lands without waiting
* out the 24h fallback TTL. Pull-to-refresh still forces a fetch anytime.
*
* @return ?array<int,array{slug:string,name:string,pages:array<int,array<string,mixed>>}>
*/
public static function fresh(): ?array
{
if (! Cache::has(self::FRESH_KEY)) {
return null;
}

$cached = Cache::get(self::CACHE_KEY, []);

return empty($cached) ? null : $cached;
}

/**
Expand All @@ -72,7 +109,7 @@ public static function search(string $query, int $limit = 15): array
// outright when it's unreachable, e.g. a nativephp.test URL from the
// Android emulator). Warm the cache once via sections() only if the
// Docs tab hasn't already populated it this session.
$sections = Cache::get('jump.docs');
$sections = Cache::get(self::CACHE_KEY);
if (empty($sections)) {
try {
$sections = static::sections();
Expand Down
Loading