diff --git a/DESIGN.md b/DESIGN.md index a57816e58..a6dd4c08b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -172,7 +172,7 @@ Every post is a commit and the timeline is the log. The system draws a literal ` The register is a well-lit terminal rather than a dark hacker cosplay. A cool near-white shell holds a white feed column cut by hairline cool-gray rules; ink is near-black. Geist carries prose, Geist Mono carries everything a machine would print: handles, short hashes, relative times, counts, character budgets, `fatal:` lines, and `null` values. Dark mode is a faithful mirror on graphite, with the lanes and primary lifted for luminance rather than redesigned. -Color is rationed. Six author lane colors exist only inside the graph and its direct echoes (profile ring, handle dot). One blue does the work of action and location: the primary button, the current nav item, the active tab underline, links. Motion is the graph's own: a node pops onto the rail when you commit, rail segments draw downward on the landing page, and a deleted commit prints `rm {hash} → /dev/null` before its row collapses. Navigation only moves to say where you went: pages crossfade in 160ms, the Blue Wash behind the current nav item and the active tab underline slide to their new place, and switching between Log and Everyone, or between the Profile and Appearance settings, slides the content toward the tab you picked while the header (and the composer) stays put. +Color is rationed. Six author lane colors exist only inside the graph and its direct echoes (profile ring, live ring, handle dot). One blue does the work of action and location: the primary button, the current nav item, the active tab underline, links. Motion is the graph's own: a node pops onto the rail when you commit, rail segments draw downward on the landing page, and a deleted commit prints `rm {hash} → /dev/null` before its row collapses. Navigation only moves to say where you went: pages crossfade in 160ms, the Blue Wash behind the current nav item and the active tab underline slide to their new place, and switching between Log and Everyone, or between the Profile and Appearance settings, slides the content toward the tab you picked while the header (and the composer) stays put. **Key Characteristics:** - A vertical graph rail with lane-colored nodes and segments is the signature and the only decorative device. @@ -191,7 +191,7 @@ A cool, nearly colorless shell with one working blue and six author lanes reserv - **Blue Wash** (`primary-soft`): fill behind the active nav item (the side nav item and the bottom bar's rounded icon background) and the selected option of the settings switches (theme, who to follow); also the text selection color. ### Secondary -- **Author Lanes** (`lane-0` through `lane-5`, burnt orange, green, violet, teal, rose, ochre; each with a `-dark` lift): chosen by the user in Settings → Profile, otherwise assigned by hashing the user id (`User::automaticLane()`); every serialized user carries the effective `lane` (0–5) and the frontend paints it with `laneColor(user.lane)`. Used only for graph nodes, rail segments, the staged composer node, the 8px dot before a handle in profile contexts, and the 2px ring around a profile avatar. Never text, never fills behind text, never buttons. +- **Author Lanes** (`lane-0` through `lane-5`, burnt orange, green, violet, teal, rose, ochre; each with a `-dark` lift): chosen by the user in Settings → Profile, otherwise assigned by hashing the user id (`User::automaticLane()`); every serialized user carries the effective `lane` (0–5) and the frontend paints it with `laneColor(user.lane)`. Used only for graph nodes, rail segments, the staged composer node, the 8px dot before a handle in profile contexts, the 2px ring around a profile avatar, and the live ring. Never text, never fills behind text, never buttons. ### Tertiary - **Rm Red** (`destructive`): delete affordances (trash icon on hover, inline confirm wash at 8% opacity, destructive button), "Unfollow" on hover, the composer counter at 0. @@ -208,6 +208,8 @@ A cool, nearly colorless shell with one working blue and six author lanes reserv ### Named Rules **The Lanes Are Identity Rule.** A lane color appears only where it marks an author inside or beside the graph. If a lane color is carrying meaning that isn't "this person", it is wrong. +**The Live Ring Rule.** Someone who is live wears one look everywhere (feed node, profile header, user preview, aside, the Orbit Rail): their avatar inside the `LiveRing`, a 2px conic ring that starts in their own lane for the first fifth and runs through the other lanes back into theirs, a 2px see-through gap, then the avatar, with the red mono LIVE pill on the ring's bottom edge. One component (`LiveAvatar` in `components/live/live-avatar.tsx`) draws it. It turns once every 9s, by rotation only, and holds still under reduced motion. No other avatar ring spins, live is never a plain red ring, and there is no second live marker. + **The One Blue Rule.** Blue means "you can act here" or "you are here". It is never decorative and never assigned to an author lane. ## Typography @@ -323,8 +325,13 @@ Under the graph, a mono 13px strip prints the streaks, `longest streak 12 days - **Thread keys:** on the focused commit, `r` opens its reply composer with the cursor in it, `q` opens its quote dialog, and `o` shows or hides its replies; focus stays on the commit (Esc or replying returns it there), so `j`/`k` keep stepping through top-level commits. The commit's own Reply and replies buttons carry `aria-keyshortcuts` and a `title` hint, with no caps in the action row. - **Activity tooltip:** one dark tooltip (Ink on Background inverted) follows the hovered cell, edge-aligned near the ends ("3 commits on Tue, Oct 14", "2 commits between 14:00 and 15:00 today"); hovering a day or hour tints that period's commits in the profile feed with Primary Soft. +### Orbit Rail +Signed in, every log tab carries one 84px row between the sticky tabs and the composer: the people you follow who are live, soonest-ending first, at most 24. The first slot is always You, a dashed Rail Gray ring with a plus that opens the Go live dialog, or your own live ring while you stream, so the row never pops in or changes height. Each item is 56px wide with 4px gaps: a 44px live ring (2px conic lane ring, 2px gap, 36px avatar) with an 8px mono LIVE pill on its bottom edge, and a 10px mono @handle under it. The row uses the column's full width and carries no count: the rings say who is live. Overflow scrolls sideways with scroll-snap, faded edges and no scrollbar chrome; ←/→/Home/End move between items, which are buttons labelled "{name} is live on {Platform} until 18:00". + +Hovering an item (mouse only, 170ms intent) opens one 520px Popover card anchored to it and clamped inside the feed column: it floats and never pushes content, swaps instantly to another avatar while open, and closes 200ms after the pointer leaves. It blooms from the ring (≈620ms, off under reduced motion) while a lane line drops from the ring to a node on the card's top edge. Click, Enter, or pressing play pins it and moves focus in; Esc closes it and returns focus. The card holds a 16:9 thumbnail whose play button swaps in the platform's player (only when embeds allow it; otherwise it links out), the name, mono handle and platform glyph, the latest commit (lane dot, relative time, short hash, three lines, linking to it), "live until 18:00 · ends in 1h 08m" in tabular mono, Watch on YouTube or Join on Twitch, and View profile. Only real data: no viewer counts, titles, or chat. The rail refreshes itself every 30s on every log tab, paused while the tab is hidden, and drops a stream the moment it ends. On touch a tap opens the same card in a sheet. The aside's Live now list is left out on log pages, since the rail shows the same people; elsewhere it lists the five streams ending soonest. + ### User Preview -Hovering an avatar, name, or handle (mouse only, 450ms delay, 200ms grace to move into it) opens a 304px Popover preview below the trigger, flipping above near the viewport bottom: a 56px avatar ringed in the user's lane, Follow / Following (Unfollow on hover) top right, name, mono handle with lane dot and a "Follows you" chip, a 3-line bio, and mono counts linking to following and followers. Follow toggles optimistically and rolls back on error. Closes on scroll, Escape, or leaving. +Hovering an avatar, name, or handle (mouse only, 450ms delay, 200ms grace to move into it) opens a 304px Popover preview below the trigger, flipping above near the viewport bottom: a 56px avatar ringed in the user's lane (the live ring while they stream), Follow / Following (Unfollow on hover) top right, name, mono handle with lane dot and a "Follows you" chip, a 3-line bio, and mono counts linking to following and followers. While they stream, a hairline-divided stream card follows: thumbnail, "{name} is live", the platform, "live until 18:00 · ends in 1h 08m", and Watch on YouTube or Join on Twitch. Its thumbnail links out instead of playing, because the preview closes on scroll. Follow toggles optimistically and rolls back on error. Closes on scroll, Escape, or leaving. ### Notifications The inbox is a log too: every row hangs on a Rail Gray rail whose node is a 36px hollow circle holding the type icon (person-plus, reply, quote, star, at, check, cross, chevrons-up). A sticky header carries the "Notifications" title over a row of page tabs (All, Replies, Quotes, Stars, Mentions, Follows, Moderation) with the sliding Commit Blue underline; on phones the row scrolls sideways with the current filter kept in view. Day markers ("Today", "Yesterday", "Wed, Oct 14") split the rows the way they split the log. A row opens with a line of stacked 32px avatars (up to three, then a mono `+N` disc) and a mono relative time pushed right, then the sentence: the actor's name (600), their mono handle when they act alone, otherwise "and N others", and the action in plain prose. Words someone else wrote (a reply, quote, or mention) follow at 15px like a commit body; your own commit (a star, an approval, a rejection) sits underneath as a hairline-bordered, muted block clamped to three lines. A level-up row replaces the avatars with a mono `Lv. 5 · wheel` chip tinted in the user's lane, says "You reached level 5: wheel." over the rank's description, and opens the profile. Moderation rows replace the avatars with a mono `approved` or `rejected` chip, and a rejection prints a mono `reason:` key before the moderator's words. Groups expand in place from a Commit Blue toggle ("5 stars" / "Hide stars"), listing one compact row per event behind a 1px Rail Gray guide line, each follower with their own Follow button. Rows the inbox marked read on this visit ring their node in Commit Blue and sit above a `HEAD@{last visit}` marker. While you read, polling adds new rows above without moving the ones on screen, and a Commit Blue button ("↑ 1 new notification") takes you to them. Empty filters speak in git's voice (`$ cat /dev/notifications | grep stars`) with a link back to all notifications; the end of the list is a hollow root node: "That's the oldest notification. There's nothing before it." diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 894c7a17c..5d7014c15 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -107,11 +107,13 @@ Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](re ### Going live -Share a stream you are running from the **…** menu on your own profile: **Go live** takes a link to a YouTube or Twitch stream (on YouTube: a `/live/` link, a watch link, an embed, or one with a start time all work; on Twitch: your channel link) and how long to show it, from **1 to 12 hours** (two by default). YouTube and Twitch are supported. While you are live, your avatar gets a red ring and a **Live** marker on your profile, in logs, and in hover cards. Hovering it (or tapping it on a touch screen) shows the stream's live thumbnail with a **Watch** button; the stream itself plays on the provider in a new tab, never embedded. On your profile, the avatar links straight to the stream. +Share a stream you are running from the **…** menu on your own profile: **Go live** takes a link to a YouTube or Twitch stream (on YouTube: a `/live/` link, a watch link, an embed, or one with a start time all work; on Twitch: your channel link) and how long to show it, from **1 to 12 hours** (two by default). YouTube and Twitch are supported. While you are live, your avatar gets a slowly turning ring in your lane color and a **Live** marker on your profile, in logs, in hover cards, and in the side column. Hovering a live avatar on a profile, or tapping one on a profile or in a log on a touch screen, shows the stream's live thumbnail with when it ends and a **Watch** button (**Join** on Twitch). Pressing play on the thumbnail plays the stream right there, through the same click-to-play [embedded players](#links-link-cards-and-embedded-players) as links, so the same embed configuration applies: when embeds are off or the platform isn't allowed, the stream opens on the provider in a new tab instead. The hover card you get from an avatar or name in a log shows the same thumbnail and button, but there the thumbnail only opens the stream on the provider, since the card closes as you scroll. On your profile, the avatar links straight to the stream. + +Signed in, every log tab (Following, Latest and Trending) opens with the **Orbit Rail**: one row of the people you follow who are live right now, soonest-ending first, up to 24 (muted and blocked people are left out). Your own slot always comes first: it opens the same **Go live** dialog, and while you stream it shows your own card with **End stream**. Hovering someone (or tapping on a touch screen) opens their card: the stream's thumbnail, which plays the stream in place like above, their name and platform, their latest commit (when they have one), when the stream ends in your time zone ("live until 18:00 · ends in 1h 08m"), and links to watch it and to their profile. Clicking someone keeps the card open until you press Esc, click elsewhere, or click them again. The rail refreshes itself every 30 seconds while the tab is in front, and drops streams as they end. Other pages show the five streams ending soonest in the side column's **Live now** box, each linking out to the stream with when it ends; logs leave that box out, since the rail already shows them. Signed out, neither appears. **End stream** in the same menu stops it at once; otherwise it stops showing when the chosen time runs out, and the scheduler clears it within five minutes. Only the platform, the link, and the end time are stored, and they are included in your export. -Sources: [go-live menu](resources/js/components/profile-actions-menu.tsx), [live avatar and card](resources/js/components/live-stream-card.tsx), [validation](app/Http/Requests/UpdateUserLiveRequest.php), [YouTube links](app/Services/LivePlatforms/YouTube.php), [Twitch links](app/Services/LivePlatforms/Twitch.php), [ending streams](app/Jobs/EndLiveStreams.php). +Sources: [go-live menu](resources/js/components/profile-actions-menu.tsx), [go-live dialog](resources/js/components/go-live-dialog.tsx), [live avatar](resources/js/components/live/live-avatar.tsx), [live card](resources/js/components/live-stream-card.tsx), [Orbit Rail](resources/js/components/orbit-rail/orbit-rail.tsx), [who is live](app/Queries/LiveFollowingQuery.php), [validation](app/Http/Requests/UpdateUserLiveRequest.php), [YouTube links](app/Services/LivePlatforms/YouTube.php), [Twitch links](app/Services/LivePlatforms/Twitch.php), [ending streams](app/Jobs/EndLiveStreams.php). ### Achievement badges and XP @@ -144,7 +146,7 @@ Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and ### Following developers and suggestions -Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. Following or unfollowing from a log updates the controls, counts, and live-following sidebar without navigating away: Latest and Trending keep their loaded commits and ranking, while Following updates its commits and empty-state suggestions. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks. +Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. Following or unfollowing from a log updates the controls, counts, and the Orbit Rail of who is live without navigating away: Latest and Trending keep their loaded commits and ranking, while Following updates its commits and empty-state suggestions. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks. **Who to follow** suggests developers in the side column or within the log on smaller screens, each with the first line of their bio and why they are suggested (how many people you follow follow them, or their follower count). Follow from the icon next to each suggestion; for someone who already follows you, it follows back. The X icon marks a suggestion **Not interested**: that developer is never suggested to you again, without muting them, and the next suggestion takes their place. Refresh for different suggestions, or use **… → Don't show again** to hide the block, with an immediate **Undo** option. Show or hide these suggestions in Settings → Appearance. Suggested developers must have a visible top-level commit. On profiles, suggestions prioritize the people that developer follows; on commit pages, they prioritize visible thread participants. Regular suggestions fill any remaining places. You can also follow back from the notifications list. diff --git a/PRODUCT.md b/PRODUCT.md index 34df477bc..07418fc5f 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -34,7 +34,8 @@ What exists, one line each (details in the knowledge base): - **Commits:** text with code, images, link cards and embedded players, or a poll; replies, quotes, threads, scheduling, translation. - **Logs:** Following, Latest, and Trending. - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). -- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. +- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube or Twitch. +- **Live:** the Orbit Rail above every log shows who you follow is streaming right now, with their latest commit and the stream one click from playing. Only what the link and the clock can tell: no viewer counts, titles, or chat. - **Remotes:** private one-to-one and group messages (orgs). - **Notifications:** in-app activity, plus independently controlled browser and sound alerts for Remotes while the site is open, enabled by default; only browser banners require notification permission. - **Search:** developers by name, commits by meaning. diff --git a/app/Actions/UpdateUserLive.php b/app/Actions/UpdateUserLive.php index a2116fca1..8984e1e3a 100644 --- a/app/Actions/UpdateUserLive.php +++ b/app/Actions/UpdateUserLive.php @@ -11,10 +11,12 @@ { public function handle(User $user, LivePlatform $platform, string $url, int $hours): void { + $endsAt = now()->addHours($hours); + $user->update([ 'live_platform' => $platform, - 'live_url' => $platform->driver()->stream($url)->url, - 'live_ends_at' => now()->addHours($hours), + 'live_url' => $platform->driver()->stream($url, $endsAt)->url, + 'live_ends_at' => $endsAt, ]); } } diff --git a/app/Contracts/Services/LivePlatformDriver.php b/app/Contracts/Services/LivePlatformDriver.php index 374991b30..0af1c220d 100644 --- a/app/Contracts/Services/LivePlatformDriver.php +++ b/app/Contracts/Services/LivePlatformDriver.php @@ -5,13 +5,14 @@ namespace App\Contracts\Services; use App\Dto\LiveStream; +use Carbon\CarbonInterface; interface LivePlatformDriver { public function accepts(string $url): bool; /** - * The stream an accepted link points to, worked out from the link alone. + * The stream an accepted link points to, worked out from the link alone, live until the given time. */ - public function stream(string $url): LiveStream; + public function stream(string $url, CarbonInterface $endsAt): LiveStream; } diff --git a/app/Dto/LiveStream.php b/app/Dto/LiveStream.php index 03a1d4732..49c8d570b 100644 --- a/app/Dto/LiveStream.php +++ b/app/Dto/LiveStream.php @@ -4,28 +4,39 @@ namespace App\Dto; +use App\Enums\LivePlatform; +use Carbon\CarbonInterface; use Illuminate\Contracts\Support\Arrayable; /** - * @implements Arrayable + * @implements Arrayable */ final readonly class LiveStream implements Arrayable { + /** + * The embed is the platform's own player, or null when embeds are turned off, so the stream only links out. + */ public function __construct( + public LivePlatform $platform, public string $url, public string $thumbnailUrl, + public CarbonInterface $endsAt, + public ?Embed $embed, ) { // } /** - * @return array{url: string, thumbnail_url: string} + * @return array{url: string, thumbnail_url: string, platform: 'youtube'|'twitch', ends_at: string, embed: array{provider: string, kind: string, src: string, aspect_ratio: string|null, height: int|null, poster: string|null}|null} */ public function toArray(): array { return [ 'url' => $this->url, 'thumbnail_url' => $this->thumbnailUrl, + 'platform' => $this->platform->value, + 'ends_at' => $this->endsAt->toIso8601ZuluString(), + 'embed' => $this->embed?->toArray(), ]; } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 7d60a25d1..5b23ffd6e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -109,11 +109,12 @@ public function share(Request $request): array 'hasUnmoderatedCommits' => fn (): bool => $isAdmin && Commit::query()->pending()->exists(), 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'showsWhoToFollow' => $showsWhoToFollow, - 'liveFollowing' => fn (): Collection => $user instanceof User && $hasAside - ? resolve(LiveFollowingQuery::class)->get($user) - : collect(), 'dimsLanes' => $request->cookie('lane_dimming') !== 'false', 'repliesPerPage' => RepliesPerPage::of($request)->value, + // Guests follow nobody. The log's poll reloads just this, and a closure only runs when it is asked for. + ...$user instanceof User && $hasAside ? [ + 'liveFollowing' => fn (): Collection => resolve(LiveFollowingQuery::class)->get($user), + ] : [], // Onboarding and the operations console have no aside. Contextual suggestions must not reuse the once-cached list across pages. ...$suggestedUsers !== null ? [ 'suggestedUsers' => ($context === null ? $suggestedUsers->once() : $suggestedUsers)->merge()->matchOn('id'), diff --git a/app/Models/User.php b/app/Models/User.php index 4360ca6ed..3ba685aad 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -559,8 +559,10 @@ public function toArray(): array */ public function liveStream(): ?LiveStream { - return $this->live_ends_at?->isFuture() === true - ? $this->live_platform?->driver()->stream((string) $this->live_url) + $endsAt = $this->live_ends_at; + + return $endsAt?->isFuture() === true + ? $this->live_platform?->driver()->stream((string) $this->live_url, $endsAt) : null; } diff --git a/app/Queries/LiveFollowingQuery.php b/app/Queries/LiveFollowingQuery.php index e868874d3..5a54925fc 100644 --- a/app/Queries/LiveFollowingQuery.php +++ b/app/Queries/LiveFollowingQuery.php @@ -4,24 +4,70 @@ namespace App\Queries; +use App\Models\Commit; use App\Models\User; use Illuminate\Support\Collection; +use Illuminate\Support\Str; final readonly class LiveFollowingQuery { /** - * @return Collection + * The people the user follows who are live, soonest ending first, each with their latest commit to say what they + * are up to. Two queries however many are live: the people with the id of their latest commit, then those commits. + * Each person carries only what the rail and the aside draw, as both reload it every 30 seconds. + * + * @return Collection> */ - public function get(User $user): Collection + public function get(User $user, int $limit = 24): Collection { - return $user->following() - ->where('live_ends_at', '>', now()) - ->whereNotNull('live_platform') - ->whereNotNull('live_url') + $latestCommit = Commit::query() + ->select('commits.id') + ->whereColumn('commits.user_id', 'users.id') + ->topLevel() + ->approved() + ->newestFirst() + ->limit(1); + + // Followed ids rather than the following relation, which would send the follow along as each person's pivot. + $people = User::query() + ->select('users.*') + ->addSelect(['latest_commit_id' => $latestCommit]) + ->whereIn('users.id', $user->following()->newPivotQuery()->select('followed_id')) + ->where('users.live_ends_at', '>', now()) + ->whereNotNull('users.live_platform') + ->whereNotNull('users.live_url') ->whereNotIn('users.id', $user->mutedIds()) ->whereNotIn('users.id', $user->idsAcrossBlocks()) - ->inRandomOrder() - ->limit(3) - ->get()->toBase(); + ->oldest('users.live_ends_at') + ->orderBy('users.id') + ->limit($limit) + ->get(); + + $ids = $people->pluck('latest_commit_id')->filter()->values()->all(); + + $commits = $ids === [] ? new Collection() : Commit::query() + ->select(['id', 'body', 'created_at']) + ->whereKey($ids) + ->get() + ->keyBy('id'); + + return $people->toBase()->map(function (User $person) use ($commits): array { + $id = $person->getAttribute('latest_commit_id'); + $commit = is_string($id) ? $commits->get($id) : null; + + return [ + 'id' => $person->id, + 'username' => $person->username, + 'name' => $person->name, + 'avatar_url' => $person->avatar_url, + 'lane' => $person->lane(), + 'live' => $person->liveStream()?->toArray(), + 'latest_commit' => $commit instanceof Commit ? [ + 'id' => $commit->id, + 'body' => Str::limit($commit->body, Commit::PREVIEW_LENGTH, '…'), + 'created_at' => $commit->created_at->toJSON(), + ] : null, + ]; + }); } } diff --git a/app/Services/Embeds.php b/app/Services/Embeds.php index 9b2847713..ed6c291c7 100644 --- a/app/Services/Embeds.php +++ b/app/Services/Embeds.php @@ -6,6 +6,7 @@ use App\Contracts\Services\EmbedProvider; use App\Dto\Embed; +use Generator; use Illuminate\Contracts\Container\Container; use Uri\WhatWg\Url; @@ -36,21 +37,21 @@ public function for(string $url): ?Embed } /** - * @return list + * Made one at a time, so a link the first providers play does not build the rest. Live streams and link cards ask + * for an embed for every one they show. + * + * @return Generator */ - private function providers(): array + private function providers(): Generator { $only = config()->array('embeds.only'); - $providers = []; foreach (config()->array('embeds.providers') as $key => $class) { $provider = is_string($class) && ($only === [] || in_array($key, $only, true)) ? $this->container->make($class) : null; if ($provider instanceof EmbedProvider) { - $providers[] = $provider; + yield $provider; } } - - return $providers; } } diff --git a/app/Services/LivePlatforms/Twitch.php b/app/Services/LivePlatforms/Twitch.php index d7df45ed6..7fdb04c81 100644 --- a/app/Services/LivePlatforms/Twitch.php +++ b/app/Services/LivePlatforms/Twitch.php @@ -6,12 +6,18 @@ use App\Contracts\Services\LivePlatformDriver; use App\Dto\LiveStream; +use App\Enums\LivePlatform; use App\Services\EmbedProviders\Twitch as TwitchEmbeds; +use App\Services\Embeds; +use Carbon\CarbonInterface; use Uri\WhatWg\Url; final readonly class Twitch implements LivePlatformDriver { - public function __construct(private TwitchEmbeds $embeds) + /** + * The link is read with the platform's embed even when embeds are turned off, but only plays inline through them. + */ + public function __construct(private TwitchEmbeds $embeds, private Embeds $players) { // } @@ -23,13 +29,16 @@ public function accepts(string $url): bool /** * The stream link points to the channel page on Twitch. - * While a channel is live, its preview thumbnail is updated periodically. + * While a channel is live, its preview thumbnail is updated periodically. 640px wide is sharp on the 184px card at + * any density and close to 2x on the widest sheet, with a quarter of the pixels of 1280px. */ - public function stream(string $url): LiveStream + public function stream(string $url, CarbonInterface $endsAt): LiveStream { $channel = $this->channel($url); - return new LiveStream("https://www.twitch.tv/{$channel}", "https://static-cdn.jtvnw.net/previews-ttv/live_user_{$channel}-1280x720.jpg"); + $link = "https://www.twitch.tv/{$channel}"; + + return new LiveStream(LivePlatform::Twitch, $link, "https://static-cdn.jtvnw.net/previews-ttv/live_user_{$channel}-640x360.jpg", $endsAt, $this->players->for($link)); } /** diff --git a/app/Services/LivePlatforms/YouTube.php b/app/Services/LivePlatforms/YouTube.php index 3598189d2..44e062b3c 100644 --- a/app/Services/LivePlatforms/YouTube.php +++ b/app/Services/LivePlatforms/YouTube.php @@ -6,12 +6,18 @@ use App\Contracts\Services\LivePlatformDriver; use App\Dto\LiveStream; +use App\Enums\LivePlatform; use App\Services\EmbedProviders\YouTube as YouTubeEmbeds; +use App\Services\Embeds; +use Carbon\CarbonInterface; use Uri\WhatWg\Url; final readonly class YouTube implements LivePlatformDriver { - public function __construct(private YouTubeEmbeds $embeds) + /** + * The link is read with the platform's embed even when embeds are turned off, but only plays inline through them. + */ + public function __construct(private YouTubeEmbeds $embeds, private Embeds $players) { // } @@ -25,11 +31,13 @@ public function accepts(string $url): bool * The link becomes its plain watch page, so a link to the embed or with a start time opens the stream itself. * While a video is live, its `hqdefault_live` thumbnail follows the stream rather than staying on its first frame. */ - public function stream(string $url): LiveStream + public function stream(string $url, CarbonInterface $endsAt): LiveStream { $videoId = $this->videoId($url); - return new LiveStream("https://www.youtube.com/watch?v={$videoId}", "https://i.ytimg.com/vi/{$videoId}/hqdefault_live.jpg"); + $link = "https://www.youtube.com/watch?v={$videoId}"; + + return new LiveStream(LivePlatform::YouTube, $link, "https://i.ytimg.com/vi/{$videoId}/hqdefault_live.jpg", $endsAt, $this->players->for($link)); } /** diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 3e9388f62..00243a62d 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,10 +4,12 @@ namespace Database\Factories; +use App\Enums\LivePlatform; use App\Enums\ModerationReason; use App\Enums\RemotesFrom; use App\Models\Ban; use App\Models\User; +use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -67,6 +69,21 @@ public function banned(ModerationReason $reason = ModerationReason::Spam): self }); } + /** + * Streaming until the given time, two hours from now by default, the way going live stores it. + */ + public function live(LivePlatform $platform = LivePlatform::YouTube, ?CarbonInterface $until = null): self + { + return $this->state(fn (array $attributes): array => [ + 'live_platform' => $platform, + 'live_url' => match ($platform) { + LivePlatform::YouTube => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + LivePlatform::Twitch => 'https://www.twitch.tv/octocat', + }, + 'live_ends_at' => $until ?? now()->addHours(2), + ]); + } + public function notOnboarded(): self { return $this->state(fn (array $attributes): array => [ diff --git a/database/migrations/2026_10_24_140000_add_live_ends_at_index_to_users_table.php b/database/migrations/2026_10_24_140000_add_live_ends_at_index_to_users_table.php new file mode 100644 index 000000000..ad90f7a6e --- /dev/null +++ b/database/migrations/2026_10_24_140000_add_live_ends_at_index_to_users_table.php @@ -0,0 +1,20 @@ + void; +}) { + const [hours, setHours] = useState(defaultHours); + const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`; + const filled = ((hours - 1) / (maxHours - 1)) * 100; + + return ( + + + Go live + + Share your YouTube or Twitch stream and your avatar gets a + live marker that sends people to it, for as long as you + choose. + + +
{ + onOpenChange(false); + setHours(defaultHours); + router.flushAll(); + }} + resetOnSuccess + > + {({ processing, errors }) => ( +
+
+ + + +
+ +
+
+ + + {hoursLabel} + +
+ + setHours( + event.currentTarget.valueAsNumber, + ) + } + aria-valuetext={hoursLabel} + style={{ + background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`, + }} + className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none" + data-test="live-hours-slider" + /> +
+ 1h + {maxHours}h +
+ +
+ + + + + + + +
+ )} +
+
+
+ ); +} + +/** + * Ends your stream (`DELETE settings/live`) and flushes cached pages so no live marker lingers; `isEnding` is true + * while the request runs. + */ +export function useEndStream(): { endStream: () => void; isEnding: boolean } { + const [isEnding, setIsEnding] = useState(false); + + const endStream = () => + router.visit(UserLiveController.destroy(), { + preserveScroll: true, + onStart: () => setIsEnding(true), + onFinish: () => setIsEnding(false), + onSuccess: () => router.flushAll(), + }); + + return { endStream, isEnding }; +} diff --git a/resources/js/components/graph-canvas.tsx b/resources/js/components/graph-canvas.tsx index 2f8f23758..d7cd1294e 100644 --- a/resources/js/components/graph-canvas.tsx +++ b/resources/js/components/graph-canvas.tsx @@ -328,6 +328,46 @@ function translationOf(element: HTMLElement): Point { return { x: px(x, element.offsetWidth), y: px(y, element.offsetHeight) }; } +/** Room around a cut-out, for the surface-colored ring a pill draws around itself. */ +const cutoutGap = 2; + +/** + * The `[data-graph-cutout]` boxes inside a node, placed around its layout `centre` in canvas pixels. Measured against + * the node's own box, so a node still scaling in reports them at their final size. + */ +function nodeCutouts( + element: HTMLElement, + centre: Point, +): NonNullable { + const cuts = element.querySelectorAll('[data-graph-cutout]'); + + if (cuts.length === 0) { + return []; + } + + const node = element.getBoundingClientRect(); + const scale = node.width > 0 ? element.offsetWidth / node.width : 1; + + return [...cuts].map((cut) => { + const rect = cut.getBoundingClientRect(); + + return { + x: snap( + centre.x + + (rect.left - node.left - node.width / 2) * scale - + cutoutGap, + ), + y: snap( + centre.y + + (rect.top - node.top - node.height / 2) * scale - + cutoutGap, + ), + width: snap(rect.width * scale + cutoutGap * 2), + height: snap(rect.height * scale + cutoutGap * 2), + }; + }); +} + function snap(value: number): number { const ratio = window.devicePixelRatio || 1; @@ -420,6 +460,10 @@ export function GraphCanvas({ children }: { children: ReactNode }) { rows.find((row) => row.element.contains(element))?.row.ids.add( spec.id, ); + const cutouts = nodeCutouts(element, { + x: centre.x - box.left, + y: centre.y - box.top, + }); return { id: spec.id, @@ -433,6 +477,7 @@ export function GraphCanvas({ children }: { children: ReactNode }) { y: snap(centre.y - box.top), // The layout width, as a node scaling in would otherwise keep the radius it had mid-animation. radius: element.offsetWidth / 2 + (spec.ring ?? 0), + ...(cutouts.length > 0 ? { cutouts } : {}), }; }); @@ -659,6 +704,21 @@ export function GraphCanvas({ children }: { children: ReactNode }) { height={size.height} fill="white" /> + {nodes.flatMap((node) => + (node.cutouts ?? []).map((cut, index) => ( + + row.ids.has(node.id), + ), + )} + /> + )), + )} {nodes.map((node) => ( ; + user: Pick; // Fills the ring clockwise from 0 to 1, as a post grows toward its limit. At 0 the ring stays dashed. progress?: number; }) { const isLive = Boolean(user.live); + if (isLive && progress === undefined) { + return ( + + + + + + ); + } + return ( {progress !== undefined && ( <> 0 ? 'opacity-0' : 'opacity-100', )} /> @@ -413,12 +424,7 @@ export function RailAvatar({ cx="24" cy="24" r="23" - className={cn( - isLive - ? 'stroke-red-600' - : 'stroke-(--lane)', - 'opacity-25', - )} + className="stroke-(--lane) opacity-25" /> )} - {isLive && ( - - )} + {/* The staged composer node keeps its progress ring while you stream, so only the pill says live. */} + {isLive && } ); } diff --git a/resources/js/components/live-badge.tsx b/resources/js/components/live-badge.tsx index 46de0d9a5..811c170c7 100644 --- a/resources/js/components/live-badge.tsx +++ b/resources/js/components/live-badge.tsx @@ -1,8 +1,10 @@ +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -export function LiveBadge({ className }: { className?: string }) { +export function LiveBadge({ className, ...props }: ComponentProps<'span'>) { return ( user.live); + const { component, props } = usePage(); + const now = useNow(); + // A stream that ends drops out with the clock, and the next one moves up into its place. + const users = visibleLive(props.liveFollowing ?? [], now).slice(0, limit); - if (users.length === 0) return null; + if (component === 'log/show' || users.length === 0) return null; return (
); } + +function LiveFollowingItem({ user }: { user: LiveFollowingUser }) { + const live = useLiveUntil(user.live.ends_at); + + return ( +
  • + + + + + + + {user.name} + + + @{user.username} + {live && ` · until ${live.until}`} + + + + +
  • + ); +} diff --git a/resources/js/components/live-stream-card.tsx b/resources/js/components/live-stream-card.tsx index ddd292cc3..98b214601 100644 --- a/resources/js/components/live-stream-card.tsx +++ b/resources/js/components/live-stream-card.tsx @@ -1,9 +1,15 @@ import { Link } from '@inertiajs/react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { ArrowUpRight } from 'lucide-react'; import type { ReactNode } from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { LiveBadge } from '@/components/live-badge'; +import { useState } from 'react'; +import { LiveAvatar } from '@/components/live/live-avatar'; +import { LiveUntil } from '@/components/live/live-preview-card'; +import { PlatformGlyph } from '@/components/live/platform-glyph'; +import { + StreamPlayer, + usePlayingStream, + WatchStreamButton, +} from '@/components/live/stream-player'; import { Button } from '@/components/ui/button'; import { DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { @@ -11,81 +17,54 @@ import { PopoverAnchor, PopoverContent, } from '@/components/ui/popover'; -import { UserAvatar } from '@/components/user-avatar'; -import { hostname } from '@/lib/urls'; -import { cn } from '@/lib/utils'; +import { useHoverIntent } from '@/hooks/use-hover-intent'; +import { platformName } from '@/lib/live'; import { show } from '@/routes/user'; import type { LiveStream, User } from '@/types'; -const openDelay = 300; -const closeDelay = 200; - /** - * The stream itself plays on the provider: the card shows what is on and sends the viewer there in a new tab. + * What is on and where, with the provider's link-out. With `canPlay` the thumbnail plays the stream in place, so + * only turn it on where the card stays put while it plays (a sheet, or a popover that pins itself through + * `onPlayingChange`); elsewhere the thumbnail links out like the button. */ export function LiveStreamCard({ user, stream, + canPlay = false, + onPlayingChange, }: { user: Pick; stream: LiveStream; + canPlay?: boolean; + onPlayingChange?: (isPlaying: boolean) => void; }) { + const [isPlaying, setIsPlaying] = usePlayingStream(stream, onPlayingChange); + return (
    - - - - +

    {user.name} is live

    -

    - {hostname(stream.url)} +

    + + {platformName(stream.platform)}

    - + +
    ); } -/** - * The avatar's ring turns red and a LIVE label sits on its edge, sized to the avatar it marks. - */ -export function LiveAvatarMarker({ className }: { className?: string }) { - return ( - - ); -} - /** * Wraps whatever marks a user as live. With a mouse, hovering it shows the stream's card, unless `hoverCard` is off - * because a user preview already shows it there. On touch, where nothing hovers, tapping it opens the card as a - * sheet instead of following the link it wraps. + * because a user preview already shows it there; pressing play pins that card open until it is dismissed. On touch, + * where nothing hovers, tapping it opens the card as a sheet instead of following the link it wraps. */ export function LiveStreamTrigger({ user, @@ -94,17 +73,23 @@ export function LiveStreamTrigger({ className, children, }: { - user: Pick; + user: Pick; hoverCard?: boolean; showProfile?: boolean; className?: string; children: ReactNode; }) { const [isHoverOpen, setIsHoverOpen] = useState(false); + const [isPinned, setIsPinned] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false); - const timer = useRef>(undefined); - - useEffect(() => () => clearTimeout(timer.current), []); + const hover = useHoverIntent({ + openDelay: 300, + onChange: (isHovering) => { + if (isHovering || !isPinned) { + setIsHoverOpen(isHovering); + } + }, + }); const stream = user.live; @@ -112,25 +97,22 @@ export function LiveStreamTrigger({ return {children}; } - const hover = (isHovering: boolean) => { - clearTimeout(timer.current); - timer.current = setTimeout( - () => setIsHoverOpen(isHovering), - isHovering ? openDelay : closeDelay, - ); - }; - return ( - + { + setIsHoverOpen(open); + + if (!open) { + setIsPinned(false); + } + }} + > - event.pointerType === 'mouse' && hover(true) - } - onPointerLeave={(event) => - event.pointerType === 'mouse' && hover(false) - } + onPointerEnter={hover.onPointerEnter} + onPointerLeave={hover.onPointerLeave} onClickCapture={(event) => { if (window.matchMedia('(hover: hover)').matches) { return; @@ -150,42 +132,64 @@ export function LiveStreamTrigger({ sideOffset={8} className="w-76 rounded-lg p-3" onOpenAutoFocus={(event) => event.preventDefault()} - onPointerEnter={(event) => - event.pointerType === 'mouse' && hover(true) - } - onPointerLeave={(event) => - event.pointerType === 'mouse' && hover(false) - } + onPointerEnter={hover.onPointerEnter} + onPointerLeave={hover.onPointerLeave} > - + + > + + + {showProfile && ( +
    + +
    +

    + {user.name} +

    +

    + @{user.username} +

    +
    + +
    + )} +
    ); } /** - * A sheet from the bottom on phones, and a small dialog on wider touch screens such as tablets. + * A sheet from the bottom on phones, and a small dialog on wider touch screens such as tablets, for whatever shows a + * stream where nothing hovers. `title` names it for screen readers; the content shows who is live. */ -function LiveStreamSheet({ - user, - stream, - showProfile, +export function LiveStreamSheet({ + title, open, onOpenChange, + children, }: { - user: Pick; - stream: LiveStream; - showProfile: boolean; + title: string; open: boolean; onOpenChange: (open: boolean) => void; + children: ReactNode; }) { return ( @@ -194,7 +198,7 @@ function LiveStreamSheet({ event.preventDefault()} - className="fixed inset-x-0 bottom-0 z-50 rounded-t-lg border-t bg-popover p-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-popover-foreground shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:w-96 sm:-translate-1/2 sm:rounded-lg sm:border sm:data-[state=closed]:fade-out-0 sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=open]:fade-in-0 sm:data-[state=open]:slide-in-from-bottom-0" + className="fixed inset-x-0 bottom-0 z-50 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain rounded-t-lg border-t bg-popover p-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-popover-foreground shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:w-96 sm:-translate-1/2 sm:rounded-lg sm:border sm:data-[state=closed]:fade-out-0 sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=open]:fade-in-0 sm:data-[state=open]:slide-in-from-bottom-0" data-test="live-stream-sheet" > - {user.name} is live + {title} - - - {showProfile && ( -
    - - - - -
    -

    - {user.name} -

    -

    - @{user.username} -

    -
    - -
    - )} + {children}
    diff --git a/resources/js/components/live/live-avatar.tsx b/resources/js/components/live/live-avatar.tsx new file mode 100644 index 000000000..298f8da34 --- /dev/null +++ b/resources/js/components/live/live-avatar.tsx @@ -0,0 +1,147 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { LiveBadge } from '@/components/live-badge'; +import { UserAvatar } from '@/components/user-avatar'; +import { cn, laneColor } from '@/lib/utils'; +import type { Lane, User } from '@/types'; + +// The lanes in the order the ring passes them, so neighbours never share a hue family. +const ringOrder = [0, 5, 1, 3, 2, 4]; + +/** + * The person's own lane holds the first fifth of the ring, then the other lanes follow it round, back into theirs. + * A custom color leads the six palette lanes the same way. + */ +function ringGradient(lane: Lane): string { + const start = typeof lane === 'number' ? ringOrder.indexOf(lane) : -1; + const others = + start === -1 + ? ringOrder + : [...ringOrder.slice(start + 1), ...ringOrder.slice(0, start)]; + const color = laneColor(lane); + const stops = others.map( + (other, index) => `var(--lane-${other}) ${110 + index * 50}deg`, + ); + + return `conic-gradient(${[`${color} 0deg 70deg`, ...stops, `${color} 360deg`].join(', ')})`; +} + +/** + * The live look for anything round: a rotating conic ring in the lane's color with a gap before `children`. It + * holds still under reduced motion. The gap is see-through, so it sits on any surface, and the ring only ever + * rotates, which the compositor does without repainting. `isGlowing` adds a soft halo for the one that is selected. + */ +function LiveRing({ + lane, + isGlowing = false, + children, +}: { + lane: Lane; + isGlowing?: boolean; + children: ReactNode; +}) { + return ( + + {isGlowing && ( + + )} + + {children} + + ); +} + +let offscreen: IntersectionObserver | undefined; + +/** + * Marks a live avatar `data-offscreen` while it is out of view, through one observer for the whole page. Chrome can't + * hand an unpainted element's animation to the compositor, so a ring spinning below the fold would otherwise + * restyle the page on the main thread every frame. + */ +function observeOffscreen(element: HTMLElement): () => void { + offscreen ??= new IntersectionObserver((entries) => { + for (const entry of entries) { + entry.target.toggleAttribute( + 'data-offscreen', + !entry.isIntersecting, + ); + } + }); + offscreen.observe(element); + + return () => offscreen?.unobserve(element); +} + +const sizes = { + 28: { avatar: 'size-7', badge: 'px-1 text-[8px] leading-3' }, + 36: { avatar: 'size-9', badge: 'px-1 text-[8px] leading-3' }, + 40: { avatar: 'size-10', badge: 'px-1 text-[9px] leading-3.5' }, + 56: { avatar: 'size-14 text-lg', badge: '' }, + 80: { avatar: 'size-20 text-2xl', badge: '' }, +} as const; + +/** + * The LIVE pill on the bottom edge of a round avatar at `size`, cut out of whatever ring it overlaps in the `surface` + * it sits on. Place it inside a `relative` box the avatar's size; `LiveAvatar` already carries it. + */ +export function LiveAvatarBadge({ + size, + surface = 'card', +}: { + size: keyof typeof sizes; + surface?: 'card' | 'popover'; +}) { + return ( + + ); +} + +/** + * A user's avatar inside a `LiveRing` with the LIVE pill on the ring's bottom edge, at the avatar's `size` in px. + * `active` marks the one whose stream card is open. + */ +export function LiveAvatar({ + user, + size, + active = false, + surface = 'card', +}: { + user: Pick; + size: keyof typeof sizes; + active?: boolean; + surface?: 'card' | 'popover'; +}) { + return ( + + + + + + + ); +} diff --git a/resources/js/components/live/live-preview-card.tsx b/resources/js/components/live/live-preview-card.tsx new file mode 100644 index 000000000..aca60eef1 --- /dev/null +++ b/resources/js/components/live/live-preview-card.tsx @@ -0,0 +1,162 @@ +import { Link } from '@inertiajs/react'; +import type { CSSProperties, ReactNode } from 'react'; +import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import { PlatformGlyph } from '@/components/live/platform-glyph'; +import { + StreamPlayer, + usePlayingStream, + WatchStreamButton, +} from '@/components/live/stream-player'; +import { useLiveUntil } from '@/hooks/use-live-until'; +import { useNow } from '@/hooks/use-now'; +import { platformName } from '@/lib/live'; +import { cn, formatRelativeTime, laneColor, shortHash } from '@/lib/utils'; +import { show } from '@/routes/user'; +import type { LiveCommit, LiveStream, User } from '@/types'; + +/** + * "live until 18:00 · ends in 1h 08m" in the reader's timezone. The line keeps its height before the browser knows + * the time, so nothing below it moves. + */ +export function LiveUntil({ + endsAt, + className, +}: { + endsAt: string; + className?: string; +}) { + const live = useLiveUntil(endsAt); + + return ( +

    + {live === null ? ( + ' ' + ) : live.isOver ? ( + 'stream ended' + ) : ( + // Each half wraps as a whole, so a narrow card never splits "10h 49m" or "8:05 AM". + <> + + live until{' '} + + {' '} + + · ends in {live.endsIn} + + + )} +

    + ); +} + +function LatestCommit({ + user, + commit, +}: { + user: Pick; + commit: LiveCommit; +}) { + const now = useNow(); + + return ( + + + + + latest commit ·{' '} + + + {shortHash(commit.id)} + + + {commit.body} + + + ); +} + +/** + * What someone is streaming: the player, who it is and where, their latest commit, when it ends, the platform's + * link-out and `actions` (such as View profile or End stream). It owns its play state and reports it through + * `onPlayingChange`, so a popover can pin itself open while a stream plays. + */ +export function LivePreviewCard({ + user, + stream, + latestCommit, + actions, + onPlayingChange, +}: { + user: Pick; + stream: LiveStream; + latestCommit?: LiveCommit | null; + actions?: ReactNode; + onPlayingChange?: (isPlaying: boolean) => void; +}) { + const [isPlaying, setIsPlaying] = usePlayingStream(stream, onPlayingChange); + + return ( +
    + {/* At 520px the thumbnail leaves the details 296px, room for Watch on YouTube and View profile on one row. */} +
    + + +
    +
    + {/* The handle gives way first, so the name stays readable next to it. */} + + {user.name} + + + @{user.username} + + + + {platformName(stream.platform)} + +
    + + {latestCommit && ( + + )} + + + +
    + + {actions} +
    +
    +
    +
    + ); +} diff --git a/resources/js/components/live/platform-glyph.tsx b/resources/js/components/live/platform-glyph.tsx new file mode 100644 index 000000000..6213a3853 --- /dev/null +++ b/resources/js/components/live/platform-glyph.tsx @@ -0,0 +1,40 @@ +import { cn } from '@/lib/utils'; +import type { LivePlatform } from '@/types'; + +/** + * The provider's mark at text size, decorative (`aria-hidden`), so pair it with the platform's name somewhere. + */ +export function PlatformGlyph({ + platform, + className, +}: { + platform: LivePlatform; + className?: string; +}) { + return ( + + {platform === 'youtube' ? ( + <> + + + + ) : ( + <> + + + + )} + + ); +} diff --git a/resources/js/components/live/stream-player.tsx b/resources/js/components/live/stream-player.tsx new file mode 100644 index 000000000..c4fef12b1 --- /dev/null +++ b/resources/js/components/live/stream-player.tsx @@ -0,0 +1,195 @@ +import { ArrowUpRight, ImageOff, Play } from 'lucide-react'; +import { useState, useSyncExternalStore } from 'react'; +import { EmbedFrame } from '@/components/link-embed'; +import { LiveBadge } from '@/components/live-badge'; +import { Button } from '@/components/ui/button'; +import { useNow } from '@/hooks/use-now'; +import { platformName, thumbnailUrl, watchLabel } from '@/lib/live'; +import { cn } from '@/lib/utils'; +import type { LiveStream, User } from '@/types'; + +const subscribe = () => () => {}; + +/** + * One attempt at one URL: a new cache-bust bucket remounts it, so a frame that failed gets another try. + */ +function ThumbnailImage({ src }: { src: string }) { + const [isBroken, setIsBroken] = useState(false); + + if (isBroken) { + return ( + + + + ); + } + + return ( + // Not lazy: it only mounts once a card or sheet opens, and a bloom that starts clipped to a point would hold + // a lazy image back until the entrance is over. + setIsBroken(true)} + className="absolute inset-0 size-full object-cover" + /> + ); +} + +/** + * The stream's 16:9 thumbnail with the 5-minute cache-bust from `thumbnailUrl`, so it never shifts layout. The + * bucket depends on the reader's clock, so the image only loads in the browser, never from the server's markup. + */ +function StreamThumbnail({ + stream, + className, +}: { + stream: Pick; + className?: string; +}) { + const isClient = useSyncExternalStore( + subscribe, + () => true, + () => false, + ); + const now = useNow(); + const src = thumbnailUrl(stream, now); + + return ( + + {isClient && } + + ); +} + +/** + * The play state for a `StreamPlayer`, one stream at a time: it is remembered by URL, so a card that starts showing + * someone else's stream starts it paused. `onPlayingChange` hears every change, so a popover can pin itself open. + */ +export function usePlayingStream( + stream: Pick, + onPlayingChange?: (isPlaying: boolean) => void, +): [boolean, (isPlaying: boolean) => void] { + const [playingUrl, setPlayingUrl] = useState(null); + + return [ + playingUrl === stream.url, + (isPlaying) => { + setPlayingUrl(isPlaying ? stream.url : null); + onPlayingChange?.(isPlaying); + }, + ]; +} + +/** + * The provider's link-out in its own words, "Watch on YouTube" or "Join on Twitch", opening in a new tab. + */ +export function WatchStreamButton({ + stream, + className, +}: { + stream: Pick; + className?: string; +}) { + return ( + + ); +} + +/** + * The thumbnail with a play button that swaps in the provider's player through `EmbedFrame` while `isPlaying`. With + * no `stream.embed` it only links out to `stream.url`. Nothing from the provider loads until play is pressed. + */ +export function StreamPlayer({ + user, + stream, + isPlaying, + onPlayingChange, +}: { + user: Pick; + stream: LiveStream; + isPlaying: boolean; + onPlayingChange: (isPlaying: boolean) => void; +}) { + const platform = platformName(stream.platform); + const frame = + 'relative block aspect-video overflow-hidden rounded-md bg-black'; + + if (isPlaying && stream.embed) { + return ( +
    + +
    + ); + } + + const poster = ( + <> + + + + + ); + + if (!stream.embed) { + return ( + + {poster} + + + + + ); + } + + return ( + + ); +} diff --git a/resources/js/components/orbit-rail/orbit-rail-card.tsx b/resources/js/components/orbit-rail/orbit-rail-card.tsx new file mode 100644 index 000000000..c3a481113 --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail-card.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from 'react'; +import { useEffect, useRef } from 'react'; +import { useMountAnimations } from '@/hooks/use-mount-animations'; + +const focusable = + 'button:not([disabled]), a[href], input:not([disabled]), select, textarea, iframe, [tabindex]:not([tabindex="-1"])'; + +/** + * The open card's chrome under the rail: a lane line dropping from the ring into a node on the card's top edge, and + * the card blooming out of that point. Keyed by whose card it is, so every swap blooms again and starts with a fresh + * player. A new `focusRequest` moves focus into the card, as a click or Enter on the slot does. + */ +export function OrbitRailCard({ + lane, + originX, + focusRequest, + children, +}: { + lane: string; + originX: number; + focusRequest: number | null; + children: ReactNode; +}) { + const card = useRef(null); + const stem = useRef(null); + const node = useRef(null); + // The bloom opens from where the ring was when the card appeared; scrolling the rail afterwards only moves the line. + useMountAnimations(() => [ + card.current?.animate( + [ + { + clipPath: `circle(0px at ${originX}px 0px)`, + opacity: 0.3, + transform: 'translateY(-8px) scale(0.985)', + }, + { + clipPath: `circle(160% at ${originX}px 0px)`, + opacity: 1, + transform: 'none', + }, + ], + { duration: 620, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, + ), + stem.current?.animate( + { transform: ['scaleY(0)', 'scaleY(1)'] }, + { duration: 220, easing: 'ease-out' }, + ), + node.current?.animate( + { transform: ['scale(0)', 'scale(1.3)', 'scale(1)'] }, + { + duration: 500, + delay: 120, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + fill: 'backwards', + }, + ), + ]); + + useEffect(() => { + if (focusRequest === null) { + return; + } + + const element = card.current; + (element?.querySelector(focusable) ?? element)?.focus(); + }, [focusRequest]); + + return ( + <> + + + + + +
    + {children} +
    + + ); +} diff --git a/resources/js/components/orbit-rail/orbit-rail-item.tsx b/resources/js/components/orbit-rail/orbit-rail-item.tsx new file mode 100644 index 000000000..7b580c45c --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail-item.tsx @@ -0,0 +1,114 @@ +import type { PointerEvent, ReactNode, Ref } from 'react'; +import { LiveAvatar } from '@/components/live/live-avatar'; +import { useLiveUntil } from '@/hooks/use-live-until'; +import { platformName } from '@/lib/live'; +import { cn } from '@/lib/utils'; +import type { LiveFollowingUser } from '@/types'; + +/** + * What every slot on the rail takes from `OrbitRail`, which owns the open card, the hover intent and the roving + * tabindex (keys are handled on the rail, so slots only report focus). + */ +export type OrbitRailSlotProps = { + ref?: Ref; + isActive: boolean; + tabIndex: 0 | -1; + onPointerEnter: (event: PointerEvent) => void; + onPointerLeave: (event: PointerEvent) => void; + onClick: () => void; + onFocus: () => void; +}; + +/** + * One person on the rail: a button with their `LiveAvatar` and @handle, labelled "{name} is live on {Platform} + * until 18:00", with `aria-expanded` and `aria-haspopup="dialog"` for the card it opens. + */ +export function OrbitRailItem({ + user, + ...slot +}: OrbitRailSlotProps & { user: LiveFollowingUser }) { + const time = useLiveUntil(user.live.ends_at); + const platform = platformName(user.live.platform); + + return ( + + + + ); +} + +/** + * The 56px-wide button every rail slot is drawn with: a 44px ring area that lifts while hovered or open, and a + * one-line mono caption under it. The rail is a list, so each slot is its own list item. + */ +export function OrbitRailSlot({ + ref, + isActive, + tabIndex, + onPointerEnter, + onPointerLeave, + onClick, + onFocus, + label, + handle, + className, + 'data-test': dataTest, + children, +}: OrbitRailSlotProps & { + label: string; + handle: string; + className?: string; + 'data-test'?: string; + children: ReactNode; +}) { + return ( +
  • + +
  • + ); +} diff --git a/resources/js/components/orbit-rail/orbit-rail-you.tsx b/resources/js/components/orbit-rail/orbit-rail-you.tsx new file mode 100644 index 000000000..5b1e9da03 --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail-you.tsx @@ -0,0 +1,89 @@ +import { Plus } from 'lucide-react'; +import { LiveAvatar } from '@/components/live/live-avatar'; +import type { OrbitRailSlotProps } from '@/components/orbit-rail/orbit-rail-item'; +import { OrbitRailSlot } from '@/components/orbit-rail/orbit-rail-item'; +import { UserAvatar } from '@/components/user-avatar'; +import { useLiveUntil } from '@/hooks/use-live-until'; +import { platformName } from '@/lib/live'; +import { cn } from '@/lib/utils'; +import type { LiveStream, Profile } from '@/types'; + +/** + * The always-first "You" slot that keeps the rail from popping in: a dashed ring with a plus that opens + * `GoLiveDialog`, or your own live ring whose card offers End stream. + */ +export function OrbitRailYou({ + user, + ...slot +}: OrbitRailSlotProps & { + user: Pick; +}) { + if (user.live) { + return ; + } + + return ( + + + + + + + + + + ); +} + +function LiveYou({ + user, + stream, + ...slot +}: OrbitRailSlotProps & { + user: Pick; + stream: LiveStream; +}) { + const time = useLiveUntil(stream.ends_at); + const platform = platformName(stream.platform); + + return ( + + + + ); +} diff --git a/resources/js/components/orbit-rail/orbit-rail.tsx b/resources/js/components/orbit-rail/orbit-rail.tsx new file mode 100644 index 000000000..8b7c4889a --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail.tsx @@ -0,0 +1,608 @@ +import { Link, usePage, usePoll } from '@inertiajs/react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { Square } from 'lucide-react'; +import type { KeyboardEvent, PointerEvent } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { GoLiveDialog, useEndStream } from '@/components/go-live-dialog'; +import { LiveStreamSheet } from '@/components/live-stream-card'; +import { LivePreviewCard } from '@/components/live/live-preview-card'; +import { OrbitRailCard } from '@/components/orbit-rail/orbit-rail-card'; +import type { OrbitRailSlotProps } from '@/components/orbit-rail/orbit-rail-item'; +import { OrbitRailItem } from '@/components/orbit-rail/orbit-rail-item'; +import { OrbitRailYou } from '@/components/orbit-rail/orbit-rail-you'; +import { Button } from '@/components/ui/button'; +import { useHoverIntent } from '@/hooks/use-hover-intent'; +import { useMountAnimations } from '@/hooks/use-mount-animations'; +import { useNow } from '@/hooks/use-now'; +import { backgroundPollOptions } from '@/lib/background-poll'; +import { isStreaming } from '@/lib/live'; +import type { CardPlacement } from '@/lib/orbit-rail'; +import { cardPlacement, nextIndex, visibleLive } from '@/lib/orbit-rail'; +import { laneColor } from '@/lib/utils'; +import { show } from '@/routes/user'; +import type { LiveFollowingUser, LiveStream, Profile } from '@/types'; + +/** The You slot's id among the people's ids; user ids are UUIDs, so it never collides. */ +const you = 'you'; + +/** Until the rail is measured, the card sits where the first ring's would. */ +const fallbackPlacement: CardPlacement = { + width: 520, + alignOffset: 0, + originX: 28, +}; + +type OpenCard = { + id: string; + /** Pinned by a click, Enter or play: hovering away no longer closes it. */ + pinned: boolean; + /** Bumped by each click or Enter, which moves focus into the card; null when hover opened it. */ + focusRequest: number | null; + placement: CardPlacement; +}; + +type SlotHandlers = Omit; + +/** + * The live-now rail between the log tabs and the composer: you first, then up to 24 people you follow from the + * shared `liveFollowing` prop, soonest-ending first, with one floating `LivePreviewCard` for whichever is open. + * Signed-in only, and a fixed 84px tall so it never shifts the feed. + */ +export function OrbitRail() { + const { + auth: { user }, + liveFollowing = [], + } = usePage().props; + + if (user === null) { + return null; + } + + return ; +} + +function Rail({ + me, + liveFollowing, +}: { + me: Profile; + liveFollowing: LiveFollowingUser[]; +}) { + // The rail refreshes itself, so ranked logs, whose page poll never starts, still see people go live and stop. + usePoll( + 30_000, + backgroundPollOptions({ only: ['liveFollowing'], preserveUrl: true }), + { mode: 'rest', background: 'pause' }, + ); + + const now = useNow(); + const time = now.getTime(); + const people = useMemo( + () => visibleLive(liveFollowing, new Date(time)), + [liveFollowing, time], + ); + const myStream: LiveStream | null = + me.live && isStreaming(me.live, now) ? me.live : null; + // Keyed by who is on the rail, not by the array, so a poll or a clock tick with the same people keeps the slots' + // handlers, and with them the slots, as they are. + const peopleKey = people.map((person) => person.id).join(' '); + const ids = useMemo( + () => [you, ...(peopleKey === '' ? [] : peopleKey.split(' '))], + [peopleKey], + ); + + const section = useRef(null); + const scroller = useRef(null); + const [slots] = useState(() => new Map()); + const [card, setCard] = useState(null); + const [sheet, setSheet] = useState<{ id: string; open: boolean } | null>( + null, + ); + const [isGoingLive, setIsGoingLive] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const [edges, setEdges] = useState({ start: true, end: true }); + const hovered = useRef(null); + const isPlaying = useRef(false); + const restoreFocus = useRef(null); + + const openPerson = card && people.find((person) => person.id === card.id); + const isOpen = + card !== null && + (card.id === you ? myStream !== null : openPerson !== undefined); + + // Whoever's card was open stopped streaming, or dropped off the rail with the last poll. + if (card !== null && !isOpen) { + setCard(null); + } + + const rovingIndex = Math.min(focusIndex, ids.length - 1); + const activeId = isOpen + ? card.id + : sheet?.open + ? sheet.id + : isGoingLive + ? you + : null; + + const place = useCallback( + (id: string): CardPlacement => { + const slot = slots.get(id)?.getBoundingClientRect(); + const column = ( + section.current?.closest('[data-main]') ?? section.current + )?.getBoundingClientRect(); + + return slot && column + ? cardPlacement({ slot, column }) + : fallbackPlacement; + }, + [slots], + ); + + // The handlers below are created once per set of slots, so they read the latest render through this ref instead of + // closing over it, and a poll or an opened card re-renders only the slots whose props actually changed. + const latest = useRef({ card, people, myStream }); + useLayoutEffect(() => { + latest.current = { card: isOpen ? card : null, people, myStream }; + }); + + const canPreview = useCallback((id: string) => { + const { people, myStream } = latest.current; + + return id === you + ? myStream !== null + : people.some((person) => person.id === id); + }, []); + + const close = useCallback(() => { + isPlaying.current = false; + setCard(null); + }, []); + + // Every way a card opens or moves to another ring measures where it goes against that ring. + const openCard = useCallback( + (id: string, card: Omit) => + setCard({ ...card, id, placement: place(id) }), + [place], + ); + + // One hover intent for every slot and the card, so moving between them shares the open delay and the close grace. + const onHoverChange = useCallback( + (isHovering: boolean) => { + if (!isHovering) { + setCard((current) => + current === null || current.pinned ? current : null, + ); + + return; + } + + const id = hovered.current; + + if (latest.current.card === null && id && canPreview(id)) { + openCard(id, { pinned: false, focusRequest: null }); + } + }, + [canPreview, openCard], + ); + const hover = useHoverIntent({ onChange: onHoverChange }); + const hoverRef = useRef(hover); + useLayoutEffect(() => { + hoverRef.current = hover; + }); + + const handlers = useMemo( + () => + new Map( + ids.map((id, index): [string, SlotHandlers] => [ + id, + { + ref: (element: HTMLButtonElement) => { + slots.set(id, element); + + return () => { + if (slots.get(id) === element) { + slots.delete(id); + } + }; + }, + onPointerEnter: (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + return; + } + + hovered.current = id; + + if (!canPreview(id)) { + return; + } + + // Once a card is open, the next ring takes it over at once, unless a stream is playing in it. + const open = latest.current.card; + if ( + open !== null && + open.id !== id && + !isPlaying.current + ) { + openCard(id, { + pinned: open.pinned, + focusRequest: null, + }); + } + + hoverRef.current.onPointerEnter(event); + }, + onPointerLeave: (event: PointerEvent) => { + if (hovered.current === id) { + hovered.current = null; + } + + hoverRef.current.onPointerLeave(event); + }, + onClick: () => { + hoverRef.current.cancel(); + + if ( + id === you && + latest.current.myStream === null + ) { + close(); + setIsGoingLive(true); + + return; + } + + // Touch screens have nothing to hover, so the card opens as a sheet instead. + if (!window.matchMedia('(hover: hover)').matches) { + close(); + setSheet({ id, open: true }); + + return; + } + + const open = latest.current.card; + + if (open?.id === id && open.pinned) { + close(); + + return; + } + + if (open?.id !== id) { + isPlaying.current = false; + } + + openCard(id, { + pinned: true, + focusRequest: (open?.focusRequest ?? 0) + 1, + }); + }, + onFocus: () => setFocusIndex(index), + }, + ]), + ), + [ids, slots, canPreview, close, openCard], + ); + + const syncScroll = useCallback(() => { + const element = scroller.current; + + if (element) { + const start = element.scrollLeft <= 1; + const end = + element.scrollLeft + element.clientWidth >= + element.scrollWidth - 1; + setEdges((current) => + current.start === start && current.end === end + ? current + : { start, end }, + ); + } + + setCard( + (current) => + current && { ...current, placement: place(current.id) }, + ); + }, [place]); + + const frame = useRef(0); + const onScroll = useCallback(() => { + cancelAnimationFrame(frame.current); + frame.current = requestAnimationFrame(syncScroll); + }, [syncScroll]); + + useLayoutEffect(() => { + const element = scroller.current; + + if (!element) { + return; + } + + const observer = new ResizeObserver(onScroll); + observer.observe(element); + if (element.firstElementChild) { + observer.observe(element.firstElementChild); + } + window.addEventListener('resize', onScroll); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', onScroll); + cancelAnimationFrame(frame.current); + }; + }, [onScroll]); + + const onKeyDown = (event: KeyboardEvent) => { + const current = ids.findIndex((id) => slots.get(id) === event.target); + const next = nextIndex( + current === -1 ? rovingIndex : current, + event.key, + ids.length, + ); + + if (next === null) { + return; + } + + event.preventDefault(); + setFocusIndex(next); + slots.get(ids[next])?.focus(); + }; + + const sheetPerson = + sheet && sheet.id !== you + ? people.find((person) => person.id === sheet.id) + : undefined; + const isMySheet = sheet?.id === you && myStream !== null; + const anchor = isOpen ? slots.get(card.id) : undefined; + const lane = laneColor(openPerson ? openPerson.lane : me.lane); + + return ( +
    +
    +
    + {people.length > 0 && ( + + )} +
      + + {people.map((person, index) => ( + + ))} +
    +
    +
    + + !open && close()} + > + {anchor && ( + + )} + + event.preventDefault()} + onCloseAutoFocus={(event) => { + event.preventDefault(); + restoreFocus.current?.focus(); + restoreFocus.current = null; + }} + onEscapeKeyDown={() => { + restoreFocus.current = + (card && slots.get(card.id)) ?? null; + }} + onInteractOutside={(event) => { + // A ring takes the card over on its own click, so pressing one must not close it first. + if ( + event.target instanceof Node && + section.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onPointerEnter={hover.onPointerEnter} + onPointerLeave={hover.onPointerLeave} + className="relative z-40 pt-3.5 outline-none" + style={{ width: card?.placement.width }} + data-test="orbit-rail-card" + > + {isOpen && ( + + {openPerson ? ( + { + isPlaying.current = playing; + if (playing) { + setCard( + (current) => + current && { + ...current, + pinned: true, + }, + ); + } + }} + /> + ) : ( + myStream && ( + + ) + )} + + )} + + + + + + setSheet((current) => current && { ...current, open }) + } + > + {sheetPerson ? ( + + setSheet( + (current) => + current && { ...current, open: false }, + ) + } + /> + ) : ( + isMySheet && + )} + + + +
    + ); +} + +/** + * The rail's lane from the first ring's centre to the last, with a pulse of the open card's lane travelling along it. + */ +function LaneLine({ color }: { color: string }) { + const traveller = useRef(null); + + // The traveller spans the whole line with the pulse at its end, so -100% to +pulse crosses any rail length. + useMountAnimations(() => [ + traveller.current?.animate( + { transform: ['translateX(-100%)', 'translateX(120px)'] }, + { duration: 5500, iterations: Infinity, easing: 'linear' }, + ), + ]); + + return ( + + + + + + ); +} + +function PersonCard({ + person, + onNavigate, + onPlayingChange, +}: { + person: LiveFollowingUser; + onNavigate: () => void; + onPlayingChange?: (isPlaying: boolean) => void; +}) { + return ( + + + View profile + + + } + /> + ); +} + +function MyCard({ me, stream }: { me: Profile; stream: LiveStream }) { + const { endStream, isEnding } = useEndStream(); + + return ( + + + End stream + + } + /> + ); +} diff --git a/resources/js/components/profile-actions-menu.tsx b/resources/js/components/profile-actions-menu.tsx index 50f43f54b..183040fa0 100644 --- a/resources/js/components/profile-actions-menu.tsx +++ b/resources/js/components/profile-actions-menu.tsx @@ -1,50 +1,21 @@ -import { Form, router } from '@inertiajs/react'; import { Ellipsis, Radio, Square } from 'lucide-react'; import { useState } from 'react'; -import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController'; -import InputError from '@/components/input-error'; +import { GoLiveDialog, useEndStream } from '@/components/go-live-dialog'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogTitle, -} from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Label } from '@/components/ui/label'; import type { User } from '@/types'; -/** - * Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live. - */ -const maxHours = 12; - -const defaultHours = 2; - /** * The quick actions on your own profile, where other people's profiles have the mute and block menu. */ export function ProfileActionsMenu({ user }: { user: Pick }) { const [isGoingLive, setIsGoingLive] = useState(false); - const [isEnding, setIsEnding] = useState(false); - const [hours, setHours] = useState(defaultHours); - const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`; - const filled = ((hours - 1) / (maxHours - 1)) * 100; - - const endStream = () => - router.visit(UserLiveController.destroy(), { - preserveScroll: true, - onStart: () => setIsEnding(true), - onFinish: () => setIsEnding(false), - onSuccess: () => router.flushAll(), - }); + const { endStream, isEnding } = useEndStream(); return ( <> @@ -82,104 +53,7 @@ export function ProfileActionsMenu({ user }: { user: Pick }) { - - - Go live - - Share your YouTube or Twitch stream and your avatar gets - a live marker that sends people to it, for as long as - you choose. - - -
    { - setIsGoingLive(false); - setHours(defaultHours); - router.flushAll(); - }} - resetOnSuccess - > - {({ processing, errors }) => ( -
    -
    - - - -
    - -
    -
    - - - {hoursLabel} - -
    - - setHours( - event.currentTarget - .valueAsNumber, - ) - } - aria-valuetext={hoursLabel} - style={{ - background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`, - }} - className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none" - data-test="live-hours-slider" - /> -
    - 1h - {maxHours}h -
    - -
    - - - - - - - -
    - )} -
    -
    -
    + ); } diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index c24f9da0b..6849678a5 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -13,17 +13,15 @@ import { useFollowHover, useIsFollowing, } from '@/components/follow-button'; -import { - LiveAvatarMarker, - LiveStreamCard, -} from '@/components/live-stream-card'; +import { LiveStreamCard } from '@/components/live-stream-card'; +import { LiveAvatar } from '@/components/live/live-avatar'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; import { UserSafetyMenu } from '@/components/user-safety-menu'; import { signIn } from '@/lib/auth'; import { exactCountTitle, formatCount } from '@/lib/numbers'; import { toastWhenThrottled } from '@/lib/throttle'; -import { cn, laneColor } from '@/lib/utils'; +import { laneColor } from '@/lib/utils'; import { preview } from '@/routes/mention'; import { show } from '@/routes/user'; import { show as userPreview } from '@/routes/user-preview'; @@ -396,16 +394,19 @@ function UserPreviewContent({ return (
    - - - {user.live && } - + {user.live ? ( + // The live ring takes the space the lane ring's offset leaves around a still avatar. + + + + ) : ( + + + + )} {!isCurrentUser && (data ? ( diff --git a/resources/js/hooks/use-hover-intent.ts b/resources/js/hooks/use-hover-intent.ts new file mode 100644 index 000000000..612d6f530 --- /dev/null +++ b/resources/js/hooks/use-hover-intent.ts @@ -0,0 +1,54 @@ +import type { PointerEvent } from 'react'; +import { useEffect, useRef } from 'react'; + +/** + * Hover intent for mouse pointers only, so touch and pen fall through to clicks: `onChange(true)` fires after + * `openDelay` ms over the target, `onChange(false)` after `closeDelay` ms away from it, and moving back in between + * cancels the pending change. Spread the handlers on every element that counts as "over", such as a trigger and + * its popover. `cancel` drops any pending change, and unmounting does too. + */ +export function useHoverIntent({ + openDelay = 170, + closeDelay = 200, + onChange, +}: { + openDelay?: number; + closeDelay?: number; + onChange: (isHovering: boolean) => void; +}): { + onPointerEnter: (event: PointerEvent) => void; + onPointerLeave: (event: PointerEvent) => void; + cancel: () => void; +} { + const timer = useRef>(undefined); + const report = useRef(onChange); + + // The timer outlives the render that set it, so it reports through the latest callback. + useEffect(() => { + report.current = onChange; + }); + + useEffect(() => () => clearTimeout(timer.current), []); + + const schedule = (isHovering: boolean) => { + clearTimeout(timer.current); + timer.current = setTimeout( + () => report.current(isHovering), + isHovering ? openDelay : closeDelay, + ); + }; + + return { + onPointerEnter: (event) => { + if (event.pointerType === 'mouse') { + schedule(true); + } + }, + onPointerLeave: (event) => { + if (event.pointerType === 'mouse') { + schedule(false); + } + }, + cancel: () => clearTimeout(timer.current), + }; +} diff --git a/resources/js/hooks/use-live-until.ts b/resources/js/hooks/use-live-until.ts new file mode 100644 index 000000000..2a367a036 --- /dev/null +++ b/resources/js/hooks/use-live-until.ts @@ -0,0 +1,33 @@ +import { usePage } from '@inertiajs/react'; +import { useSyncExternalStore } from 'react'; +import { useNow } from '@/hooks/use-now'; +import { formatEndsIn, formatLiveUntil, isStreaming } from '@/lib/live'; + +const subscribe = () => () => {}; + +/** + * When a stream ends, ticking with `useNow`: `until` like "18:00" in the timezone the viewer chose (their browser's + * when they chose none, like every other time on the site), `endsIn` like "1h 08m", and `isOver` once `endsAt` has + * passed. Null during SSR and the first render, so server and client markup match like `local-time.tsx`. + */ +export function useLiveUntil( + endsAt: string, +): { until: string; endsIn: string; isOver: boolean } | null { + const isClient = useSyncExternalStore( + subscribe, + () => true, + () => false, + ); + const now = useNow(); + const timeZone = usePage().props.auth.user?.timezone ?? undefined; + + if (!isClient) { + return null; + } + + return { + until: formatLiveUntil(endsAt, timeZone), + endsIn: formatEndsIn(endsAt, now), + isOver: !isStreaming({ ends_at: endsAt }, now), + }; +} diff --git a/resources/js/hooks/use-mount-animations.ts b/resources/js/hooks/use-mount-animations.ts new file mode 100644 index 000000000..71090adcc --- /dev/null +++ b/resources/js/hooks/use-mount-animations.ts @@ -0,0 +1,22 @@ +import { useLayoutEffect, useRef } from 'react'; + +/** + * Plays the Web Animations that `play` starts, once, as the component mounts (before paint, so nothing flashes in its + * final state first), and cancels them when it unmounts. Under reduced motion nothing plays. `play` is read from the + * first render, so key the component to play its entrance again. + */ +export function useMountAnimations( + play: () => (Animation | undefined)[], +): void { + const start = useRef(play); + + useLayoutEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return; + } + + const animations = start.current(); + + return () => animations.forEach((animation) => animation?.cancel()); + }, []); +} diff --git a/resources/js/lib/graph.ts b/resources/js/lib/graph.ts index eadfffee7..77ea44442 100644 --- a/resources/js/lib/graph.ts +++ b/resources/js/lib/graph.ts @@ -15,6 +15,8 @@ export type GraphNode = Point & { dimmed?: boolean; /** On its way out, like an end dot scaling out as the thread goes on below it: linked to nothing, it only keeps its cut-out. */ isLeaving?: boolean; + /** Boxes the node carries past its ring, such as a LIVE pill on its edge, which lines pass behind too. */ + cutouts?: (Point & { width: number; height: number })[]; }; export type GraphEdge = { diff --git a/resources/js/lib/live.ts b/resources/js/lib/live.ts new file mode 100644 index 000000000..8ad55696a --- /dev/null +++ b/resources/js/lib/live.ts @@ -0,0 +1,99 @@ +import type { LivePlatform, LiveStream } from '@/types'; + +// Only type imports from `@/` here: `tests/Unit/*.test.ts` load this file straight from node, which cannot resolve them. + +const MINUTE = 60_000; +const THUMBNAIL_BUCKET = 5 * MINUTE; + +const platformNames: Record = { + youtube: 'YouTube', + twitch: 'Twitch', +}; + +const watchLabels: Record = { + youtube: 'Watch on YouTube', + twitch: 'Join on Twitch', +}; + +/** + * Whether the stream is still on at `now`, which is the moment `ends_at` has not passed yet. + */ +export function isStreaming( + stream: Pick, + now: Date, +): boolean { + return new Date(stream.ends_at).getTime() > now.getTime(); +} + +// Building a formatter is what costs, about half a millisecond each, so every ring on the rail shares one per timezone. +const clockFormats = new Map(); + +/** + * The clock time a stream ends at in a timezone (the browser's own when omitted), like "18:00" or "6:00 PM" in the + * browser's locale, the same format as `formatZonedTime`. + */ +export function formatLiveUntil(endsAt: string, timeZone?: string): string { + let format = clockFormats.get(timeZone); + + if (format === undefined) { + format = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + timeZone, + }); + clockFormats.set(timeZone, format); + } + + return format.format(new Date(endsAt)); +} + +/** + * How long until the stream ends, in whole minutes rounded up: "2h 10m", "1h 08m", "45m", and "0m" once it is over. + */ +export function formatEndsIn(endsAt: string, now: Date): string { + const left = new Date(endsAt).getTime() - now.getTime(); + const minutes = left > 0 ? Math.ceil(left / MINUTE) : 0; + const hours = Math.floor(minutes / 60); + + if (hours === 0) { + return `${minutes}m`; + } + + return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`; +} + +/** + * The provider's display name: "YouTube" or "Twitch". + */ +export function platformName(platform: LivePlatform): string { + return platformNames[platform]; +} + +/** + * The link-out label in each provider's own words: "Watch on YouTube" or "Join on Twitch". + */ +export function watchLabel(platform: LivePlatform): string { + return watchLabels[platform]; +} + +/** + * The thumbnail URL with a `t` param set to the current 5-minute bucket, so polling refreshes a stale frame while + * every render inside the same bucket keeps the same URL (and the browser cache). + */ +export function thumbnailUrl( + stream: Pick, + now: Date, +): string { + const bucket = String(Math.floor(now.getTime() / THUMBNAIL_BUCKET)); + + try { + const url = new URL(stream.thumbnail_url); + + url.searchParams.set('t', bucket); + + return url.toString(); + } catch { + // The backend only sends absolute URLs, so a malformed one is shown as it is rather than breaking the render. + return stream.thumbnail_url; + } +} diff --git a/resources/js/lib/orbit-rail.ts b/resources/js/lib/orbit-rail.ts new file mode 100644 index 000000000..bb29ef0ea --- /dev/null +++ b/resources/js/lib/orbit-rail.ts @@ -0,0 +1,85 @@ +import type { LiveStream } from '@/types'; + +// Only type imports from `@/` here: `tests/Unit/*.test.ts` load this file straight from node, which cannot resolve them. + +/** + * The people still live at `now`: drops anyone whose `live.ends_at` is at or before `now` and keeps the server's + * order (soonest-ending first), so the rail empties itself between polls without a reload. + */ +export function visibleLive }>( + users: readonly T[], + now: Date, +): T[] { + const time = now.getTime(); + + return users.filter((user) => new Date(user.live.ends_at).getTime() > time); +} + +/** + * The roving tabindex target for a key pressed on the item at `current` out of `count` items: ArrowRight moves to + * the next and ArrowLeft to the previous, both wrapping around the ends, Home goes to the first and End to the + * last. Returns null for any other key, or when there are no items, so the caller leaves the event alone. + */ +export function nextIndex( + current: number, + key: string, + count: number, +): number | null { + if (count === 0) { + return null; + } + + switch (key) { + case 'ArrowRight': + return (current + 1) % count; + case 'ArrowLeft': + return (current - 1 + count) % count; + case 'Home': + return 0; + case 'End': + return count - 1; + default: + return null; + } +} + +export type CardPlacement = { + /** The card's width: 520px, or the column less its gutters when that is narrower. */ + width: number; + /** How far the card's left edge sits from the slot's left edge, for the popover's `alignOffset`. */ + alignOffset: number; + /** The slot's centre measured from the card's left edge, where the bloom starts and the lane line lands. */ + originX: number; +}; + +/** + * Where the stream card sits under a rail slot, from the slot's and the feed column's horizontal extents in viewport + * pixels: it leans left of the ring by `lead` so the ring lands near the card's start, and is clamped inside the + * column with a `gutter` on both sides, so it floats over the feed without ever leaving it. + */ +export function cardPlacement({ + slot, + column, + gutter = 16, + maxWidth = 520, + lead = 64, +}: { + slot: { left: number; width: number }; + column: { left: number; width: number }; + gutter?: number; + maxWidth?: number; + lead?: number; +}): CardPlacement { + const width = Math.max(0, Math.min(maxWidth, column.width - gutter * 2)); + const centre = slot.left + slot.width / 2; + const min = column.left + gutter; + const max = Math.max(min, column.left + column.width - gutter - width); + const left = Math.min(Math.max(centre - lead, min), max); + + return { + width, + alignOffset: left - slot.left, + // A slot scrolled out of the rail still points its line at the card's nearest corner, not past it. + originX: Math.min(Math.max(centre - left, 8), Math.max(8, width - 8)), + }; +} diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 623108219..458f73371 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -14,6 +14,7 @@ import { useCommitDialogClosedCount } from '@/components/commit-composer-dialog' import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; import { FollowButton } from '@/components/follow-button'; import { LinkifiedText } from '@/components/linkified-text'; +import { OrbitRail } from '@/components/orbit-rail/orbit-rail'; import { PageTabs } from '@/components/page-tabs'; import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; @@ -550,6 +551,8 @@ export default function Show({ />
    + {user !== null && } + {user !== null && (
    diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 7416d8021..83e26a637 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -22,10 +22,8 @@ import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; import { EditProfileMenu } from '@/components/edit-profile-menu'; import { FollowButton } from '@/components/follow-button'; import { GitHubIcon } from '@/components/github-icon'; -import { - LiveAvatarMarker, - LiveStreamTrigger, -} from '@/components/live-stream-card'; +import { LiveStreamTrigger } from '@/components/live-stream-card'; +import { LiveAvatar } from '@/components/live/live-avatar'; import { LocalTime } from '@/components/local-time'; import { PageTabs } from '@/components/page-tabs'; import { PinCommitButton } from '@/components/pin-commit-button'; @@ -42,6 +40,7 @@ import { UserSafetyMenu } from '@/components/user-safety-menu'; import { refreshSharedState, useSharedState } from '@/hooks/use-shared-state'; import AppLayout from '@/layouts/app-layout'; import { commitStateKey } from '@/lib/commit-state'; +import { platformName } from '@/lib/live'; import { exactCountTitle, formatCount } from '@/lib/numbers'; import { dateLocale, laneColor, laneTint } from '@/lib/utils'; import type { @@ -138,16 +137,12 @@ export default function Show({ href={user.live.url} target="_blank" rel="noopener noreferrer" - aria-label={`Watch ${user.name} live on ${user.live.url.includes('twitch.tv') ? 'Twitch' : 'YouTube'}`} - className="relative flex rounded-full" + aria-label={`Watch ${user.name} live on ${platformName(user.live.platform)}`} + // The live ring takes the space the lane ring's offset leaves around a still avatar. + className="-m-1 flex rounded-full" data-test="profile-live-avatar" > - - + ) : ( diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 1a74ae1f3..753318940 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -1,4 +1,5 @@ import type { RemotesFrom } from '@/types/conversation'; +import type { LiveStream } from '@/types/live'; import type { Log } from '@/types/log'; /** @@ -6,11 +7,6 @@ import type { Log } from '@/types/log'; */ export type Lane = number | string; -export type LiveStream = { - url: string; - thumbnail_url: string; -}; - export type User = { id: string; username: string; diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 76c7a78a4..3bfbdcbe2 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -1,5 +1,6 @@ -import type { Auth, SuggestedUser, User } from '@/types/auth'; +import type { Auth, SuggestedUser } from '@/types/auth'; import type { Commit } from '@/types/commit'; +import type { LiveFollowingUser } from '@/types/live'; import type { CommittedFlash, DeletedFlash, @@ -30,7 +31,7 @@ declare module '@inertiajs/core' { dimsLanes: boolean; repliesPerPage: number; suggestedUsers?: SuggestedUser[]; - liveFollowing?: User[]; + liveFollowing?: LiveFollowingUser[]; [key: string]: unknown; }; } diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 0b44a62df..e038c8247 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -4,6 +4,7 @@ export type * from './auth'; export type * from './commit'; export type * from './conversation'; export type * from './device'; +export type * from './live'; export type * from './log'; export type * from './navigation'; export type * from './notification'; diff --git a/resources/js/types/live.ts b/resources/js/types/live.ts new file mode 100644 index 000000000..72d18584f --- /dev/null +++ b/resources/js/types/live.ts @@ -0,0 +1,44 @@ +import type { User } from '@/types/auth'; +import type { Embed } from '@/types/commit'; + +/** + * The providers a stream can be on, mirroring the backend's live URL validation. + */ +export type LivePlatform = 'youtube' | 'twitch'; + +/** + * What `User::toArray()` sends as `live` while the user streams, from the `LiveStream` DTO: + * `{url: string, thumbnail_url: string, platform: 'youtube'|'twitch', ends_at: ISO 8601 string, + * embed: Embed|null}`. `embed` comes from `App\Services\Embeds::for($url)`, so it is null whenever embeds are off + * or the provider is not allowed, and the stream then only links out. + */ +export type LiveStream = { + url: string; + thumbnail_url: string; + platform: LivePlatform; + ends_at: string; + embed: Embed | null; +}; + +/** + * The newest approved top-level commit of someone who is live: `{id: string, body: string (Str::limit 280), + * created_at: ISO 8601 string}`. + */ +export type LiveCommit = { + id: string; + body: string; + created_at: string; +}; + +/** + * One entry of the shared `liveFollowing` prop from `LiveFollowingQuery::get()`: just what the rail and the aside + * draw of the person, with `live` always set and `latest_commit` (null when they have none). Sorted soonest-ending + * first, at most 24. + */ +export type LiveFollowingUser = Pick< + User, + 'id' | 'username' | 'name' | 'avatar_url' | 'lane' +> & { + live: LiveStream; + latest_commit: LiveCommit | null; +}; diff --git a/tests/Feature/Controllers/UserLiveControllerTest.php b/tests/Feature/Controllers/UserLiveControllerTest.php index a8b683713..e98554664 100644 --- a/tests/Feature/Controllers/UserLiveControllerTest.php +++ b/tests/Feature/Controllers/UserLiveControllerTest.php @@ -8,11 +8,7 @@ function liveUser(): User { - return User::factory()->create([ - 'live_platform' => LivePlatform::YouTube, - 'live_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - 'live_ends_at' => now()->addHours(2), - ]); + return User::factory()->live()->create(); } it('goes live with a YouTube stream for a few hours', function (string $url): void { @@ -32,6 +28,16 @@ function liveUser(): User ->where('user.live', [ 'url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'thumbnail_url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault_live.jpg', + 'platform' => 'youtube', + 'ends_at' => now()->addHours(3)->toIso8601ZuluString(), + 'embed' => [ + 'provider' => 'YouTube', + 'kind' => 'video', + 'src' => 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1', + 'aspect_ratio' => '16 / 9', + 'height' => null, + 'poster' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', + ], ])); })->with([ 'live link' => 'https://www.youtube.com/live/dQw4w9WgXcQ?si=share', @@ -55,7 +61,17 @@ function liveUser(): User ->assertInertia(fn ($page) => $page ->where('user.live', [ 'url' => 'https://www.twitch.tv/octocat', - 'thumbnail_url' => 'https://static-cdn.jtvnw.net/previews-ttv/live_user_octocat-1280x720.jpg', + 'thumbnail_url' => 'https://static-cdn.jtvnw.net/previews-ttv/live_user_octocat-640x360.jpg', + 'platform' => 'twitch', + 'ends_at' => now()->addHours(3)->toIso8601ZuluString(), + 'embed' => [ + 'provider' => 'Twitch', + 'kind' => 'video', + 'src' => 'https://player.twitch.tv/?channel=octocat&parent='.parse_url(config()->string('app.url'), PHP_URL_HOST).'&autoplay=true', + 'aspect_ratio' => '16 / 9', + 'height' => null, + 'poster' => null, + ], ])); })->with([ 'channel link' => 'https://www.twitch.tv/octocat', @@ -63,6 +79,20 @@ function liveUser(): User 'mobile link' => 'https://m.twitch.tv/octocat', ]); +it('only links out to the stream when it may not play inline', function (array $embeds, LivePlatform $platform): void { + config(['embeds' => [...config()->array('embeds'), ...$embeds]]); + $user = User::factory()->live($platform)->create(); + + $this->get(route('user.show', $user)) + ->assertInertia(fn ($page) => $page + ->where('user.live.platform', $platform->value) + ->where('user.live.url', $platform === LivePlatform::YouTube ? 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' : 'https://www.twitch.tv/octocat') + ->where('user.live.embed', null)); +})->with([ + 'embeds off' => [['enabled' => false]], + 'platform not allowed' => [['only' => ['vimeo']]], +])->with([LivePlatform::YouTube, LivePlatform::Twitch]); + it('shows the live marker on their commits in the log', function (): void { $user = liveUser(); Commit::factory()->for($user)->create(); diff --git a/tests/Feature/LiveFollowingQueryTest.php b/tests/Feature/LiveFollowingQueryTest.php deleted file mode 100644 index 127a1e3c3..000000000 --- a/tests/Feature/LiveFollowingQueryTest.php +++ /dev/null @@ -1,51 +0,0 @@ -create(); - $people = User::factory()->count(6)->create([ - 'live_platform' => LivePlatform::YouTube, - 'live_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - 'live_ends_at' => now()->addHours(2), - ]); - [$live, $expired, $muted, $blocked, $blocker, $stranger] = $people->all(); - $viewer->following()->attach([$live->id, $expired->id, $muted->id, $blocked->id, $blocker->id]); - $expired->update(['live_ends_at' => now()->subMinute()]); - $viewer->muting()->attach($muted); - $viewer->blocking()->attach($blocked); - $blocker->blocking()->attach($viewer); - - expect(resolve(LiveFollowingQuery::class)->get($viewer)->pluck('id')->all())->toBe([$live->id]); -}); - -it('returns no live people when none are followed', function (): void { - expect(resolve(LiveFollowingQuery::class)->get(User::factory()->create()))->toBeEmpty(); -}); - -it('shows at most three live people and excludes followers the viewer does not follow back', function (): void { - $viewer = User::factory()->create(); - $live = [ - 'live_platform' => LivePlatform::YouTube, - 'live_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - 'live_ends_at' => now()->addHours(2), - ]; - $followed = User::factory()->count(5)->create($live); - $viewer->following()->attach($followed->modelKeys()); - $follower = User::factory()->create($live); - $follower->following()->attach($viewer); - - $shown = resolve(LiveFollowingQuery::class)->get($viewer); - - expect($shown)->toHaveCount(3); - foreach ($shown as $person) { - expect($followed->modelKeys())->toContain($person->id); - } - - $viewer->following()->detach(); - expect(resolve(LiveFollowingQuery::class)->get($viewer))->toBeEmpty(); -}); diff --git a/tests/Feature/LiveFollowingTest.php b/tests/Feature/LiveFollowingTest.php new file mode 100644 index 000000000..679080f41 --- /dev/null +++ b/tests/Feature/LiveFollowingTest.php @@ -0,0 +1,160 @@ +actingAs($viewer)->withHeaders([ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) resolve(HandleInertiaRequests::class)->version(request()), + 'X-Inertia-Partial-Component' => 'log/show', + 'X-Inertia-Partial-Data' => $props, + ])->get(route('log.show', Log::Latest)); +} + +it('lists the people the user follows who are live, soonest ending first', function (): void { + $viewer = User::factory()->create(); + $later = User::factory()->live(until: now()->addHours(3))->create(); + $sooner = User::factory()->live(LivePlatform::Twitch, until: now()->addHour())->count(2)->create()->sortBy('id')->values(); + $ended = User::factory()->live(until: now()->subMinute())->create(); + $offline = User::factory()->create(); + $stranger = User::factory()->live()->create(); + $viewer->following()->attach([$later->id, ...$sooner->modelKeys(), $ended->id, $offline->id]); + $stranger->following()->attach($viewer); + + reloadLog($viewer)->assertOk() + ->assertJsonPath('props.liveFollowing.*.id', [$sooner[0]->id, $sooner[1]->id, $later->id]); + + $this->travel(1)->hours(); + + reloadLog($viewer)->assertOk() + ->assertJsonPath('props.liveFollowing.*.id', [$later->id]); +}); + +it('sends each live person with only what the rail draws, their stream and latest commit', function (): void { + $viewer = User::factory()->create(); + $live = User::factory()->live(LivePlatform::Twitch)->create(); + $commit = Commit::factory()->for($live)->create(['body' => str_repeat('a', 400), 'created_at' => now()->subMinute()]); + $viewer->following()->attach($live); + + reloadLog($viewer)->assertOk() + ->assertJsonPath('props.liveFollowing.0.username', $live->username) + ->assertJsonPath('props.liveFollowing.0.lane', $live->lane()) + ->assertJsonPath('props.liveFollowing.0.live', [ + 'url' => 'https://www.twitch.tv/octocat', + 'thumbnail_url' => 'https://static-cdn.jtvnw.net/previews-ttv/live_user_octocat-640x360.jpg', + 'platform' => 'twitch', + 'ends_at' => now()->addHours(2)->toIso8601ZuluString(), + 'embed' => [ + 'provider' => 'Twitch', + 'kind' => 'video', + 'src' => 'https://player.twitch.tv/?channel=octocat&parent='.parse_url(config()->string('app.url'), PHP_URL_HOST).'&autoplay=true', + 'aspect_ratio' => '16 / 9', + 'height' => null, + 'poster' => null, + ], + ]) + ->assertJsonPath('props.liveFollowing.0.latest_commit', [ + 'id' => $commit->id, + 'body' => Str::limit($commit->body, Commit::PREVIEW_LENGTH, '…'), + 'created_at' => now()->subMinute()->startOfSecond()->toJSON(), + ]) + ->assertJsonMissingPath('props.liveFollowing.0.latest_commit_id') + ->assertJsonMissingPath('props.liveFollowing.0.pivot') + ->assertJsonMissingPath('props.liveFollowing.0.live_url') + ->assertJsonMissingPath('props.liveFollowing.0.bio'); +}); + +it('takes the newest approved top-level commit, skipping replies, pending and deleted commits', function (): void { + $viewer = User::factory()->create(); + [$live, $quiet] = User::factory()->live()->count(2)->create()->sortBy('id')->values()->all(); + $viewer->following()->attach([$live->id, $quiet->id]); + $latest = Commit::factory()->for($live)->create(['created_at' => now()->subHour()]); + Commit::factory()->for($live)->create(['created_at' => now()->subHours(2)]); + Commit::factory()->for($live)->for(Commit::factory(), 'parent')->create(['created_at' => now()->subMinutes(4)]); + Commit::factory()->for($live)->pending()->create(['created_at' => now()->subMinutes(3)]); + Commit::factory()->for($live)->create(['created_at' => now()->subMinutes(2)])->delete(); + Commit::factory()->for($quiet)->for(Commit::factory(), 'parent')->create(); + + reloadLog($viewer)->assertOk() + ->assertJsonPath('props.liveFollowing.0.latest_commit.id', $latest->id) + ->assertJsonPath('props.liveFollowing.1.id', $quiet->id) + ->assertJsonPath('props.liveFollowing.1.latest_commit', null); +}); + +it('leaves out the people the user mutes, blocks, is blocked by or unfollows', function (): void { + $viewer = User::factory()->create(); + [$live, $muted, $blocked, $blocker, $unfollowed] = User::factory()->live()->count(5)->create()->all(); + $viewer->following()->attach([$live->id, $muted->id, $blocked->id, $blocker->id, $unfollowed->id]); + $viewer->muting()->attach($muted); + $viewer->blocking()->attach($blocked); + $blocker->blocking()->attach($viewer); + $viewer->following()->detach($unfollowed); + + reloadLog($viewer)->assertOk() + ->assertJsonPath('props.liveFollowing.*.id', [$live->id]); +}); + +it('shows at most 24 people in two queries however many are live', function (): void { + $viewer = User::factory()->create(['log' => Log::Latest, 'last_seen_at' => today()]); + $queries = function () use ($viewer): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + reloadLog($viewer)->assertOk(); + + return count(DB::getQueryLog()); + }; + $follow = function (int $count) use ($viewer): void { + $people = User::factory()->live()->count($count)->has(Commit::factory()->count(2))->create(); + $viewer->following()->attach($people); + }; + + $follow(1); + $expected = $queries(); + $follow(25); + + expect($queries())->toBe($expected); + reloadLog($viewer)->assertJsonCount(24, 'props.liveFollowing'); +}); + +it('only looks up live people when the log reloads them', function (): void { + $viewer = User::factory()->create(); + $viewer->following()->attach(User::factory()->live()->create()); + + DB::enableQueryLog(); + + reloadLog($viewer, 'newCommitsCount')->assertOk() + ->assertJsonMissingPath('props.liveFollowing'); + + expect(collect(DB::getQueryLog())->pluck('query'))->each->not->toContain('live_ends_at'); + + reloadLog($viewer)->assertOk() + ->assertJsonCount(1, 'props.liveFollowing') + ->assertJsonMissingPath('props.commits') + ->assertJsonMissingPath('props.newCommitsCount'); +}); + +it('shares live people on full page loads, but not with guests', function (): void { + $viewer = User::factory()->create(); + $live = User::factory()->live()->create(); + $viewer->following()->attach($live); + + $this->get(route('log.show', Log::Latest)) + ->assertInertia(fn ($page) => $page->missing('liveFollowing')); + + $this->actingAs($viewer)->get(route('log.show', Log::Latest)) + ->assertInertia(fn ($page) => $page->where('liveFollowing.0.id', $live->id)); +}); diff --git a/tests/Unit/Live.test.ts b/tests/Unit/Live.test.ts new file mode 100644 index 000000000..3fb1c2f9b --- /dev/null +++ b/tests/Unit/Live.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + formatEndsIn, + formatLiveUntil, + isStreaming, + thumbnailUrl, +} from '../../resources/js/lib/live.ts'; + +const now = new Date('2026-10-24T16:00:00Z'); + +void test('a stream is on until the moment it ends', () => { + assert.equal(isStreaming({ ends_at: '2026-10-24T16:00:01Z' }, now), true); + assert.equal(isStreaming({ ends_at: '2026-10-24T16:00:00Z' }, now), false); + assert.equal(isStreaming({ ends_at: '2026-10-24T15:00:00Z' }, now), false); +}); + +void test('the end time is the clock time in the viewer timezone', () => { + assert.match( + formatLiveUntil('2026-10-24T16:00:00Z', 'Europe/Berlin'), + /^(18:00|6:00\sPM)$/, + ); + assert.match( + formatLiveUntil('2026-10-24T16:05:00Z', 'America/New_York'), + /^(12:05|12:05\sPM)$/, + ); +}); + +void test('the time left rounds up to whole minutes with padded minutes after hours', () => { + assert.equal(formatEndsIn('2026-10-24T18:10:00Z', now), '2h 10m'); + assert.equal(formatEndsIn('2026-10-24T17:07:01Z', now), '1h 08m'); + assert.equal(formatEndsIn('2026-10-24T18:00:00Z', now), '2h 00m'); + assert.equal(formatEndsIn('2026-10-24T16:45:00Z', now), '45m'); + assert.equal(formatEndsIn('2026-10-24T16:00:01Z', now), '1m'); +}); + +void test('a stream that is over has no time left', () => { + assert.equal(formatEndsIn('2026-10-24T16:00:00Z', now), '0m'); + assert.equal(formatEndsIn('2026-10-24T12:00:00Z', now), '0m'); +}); + +void test('the thumbnail URL stays the same within five minutes and changes after', () => { + const stream = { + thumbnail_url: + 'https://static-cdn.jtvnw.net/previews-ttv/live_user_dev-1280x720.jpg', + }; + const first = thumbnailUrl(stream, new Date('2026-10-24T16:00:00Z')); + + assert.equal(thumbnailUrl(stream, new Date('2026-10-24T16:04:59Z')), first); + assert.notEqual( + thumbnailUrl(stream, new Date('2026-10-24T16:05:00Z')), + first, + ); + assert.match( + first, + /^https:\/\/static-cdn\.jtvnw\.net\/previews-ttv\/live_user_dev-1280x720\.jpg\?t=\d+$/, + ); +}); + +void test('the thumbnail cache bust keeps the query the URL already has', () => { + const url = new URL( + thumbnailUrl( + { + thumbnail_url: + 'https://i.ytimg.com/vi/abc/hqdefault_live.jpg?v=2', + }, + now, + ), + ); + + assert.equal(url.searchParams.get('v'), '2'); + assert.ok(url.searchParams.has('t')); + assert.equal(url.pathname, '/vi/abc/hqdefault_live.jpg'); +}); diff --git a/tests/Unit/Middleware/HandleInertiaRequestsTest.php b/tests/Unit/Middleware/HandleInertiaRequestsTest.php index a73123170..1e2e393ad 100644 --- a/tests/Unit/Middleware/HandleInertiaRequestsTest.php +++ b/tests/Unit/Middleware/HandleInertiaRequestsTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Enums\CommitStatus; -use App\Enums\LivePlatform; use App\Http\Middleware\HandleInertiaRequests; use App\Models\Commit; use App\Models\Notification; @@ -319,12 +318,7 @@ it('leaves who to follow and live following out of the operations console, which has no aside', function (string $path): void { $middleware = new HandleInertiaRequests(); $user = User::factory()->create(); - $live = User::factory()->create([ - 'live_platform' => LivePlatform::YouTube, - 'live_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - 'live_ends_at' => now()->addHour(), - ]); - $user->following()->attach($live); + $user->following()->attach(User::factory()->live()->create()); $request = Request::create($path, 'GET'); $request->setUserResolver(fn () => $user); @@ -332,7 +326,7 @@ $shared = $middleware->share($request); expect($shared)->not->toHaveKey('suggestedUsers') - ->and($shared['liveFollowing']())->toBeEmpty(); + ->and($shared)->not->toHaveKey('liveFollowing'); })->with(['/operations', '/operations/events']); it('suggests the round of people the user refreshed to', function (string $cookie, int $round): void { diff --git a/tests/Unit/OrbitRail.test.ts b/tests/Unit/OrbitRail.test.ts new file mode 100644 index 000000000..f530d7a98 --- /dev/null +++ b/tests/Unit/OrbitRail.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + cardPlacement, + nextIndex, + visibleLive, +} from '../../resources/js/lib/orbit-rail.ts'; + +const now = new Date('2026-10-24T16:00:00Z'); + +function live(id: string, endsAt: string) { + return { id, live: { ends_at: endsAt } }; +} + +void test('drops people whose stream has ended and keeps the soonest-ending order', () => { + const users = [ + live('ended', '2026-10-24T15:59:00Z'), + live('ending-now', '2026-10-24T16:00:00Z'), + live('soon', '2026-10-24T16:00:01Z'), + live('later', '2026-10-24T18:00:00Z'), + ]; + + assert.deepEqual( + visibleLive(users, now).map((user) => user.id), + ['soon', 'later'], + ); +}); + +void test('moves through the rail with the arrow keys, wrapping at both ends', () => { + assert.equal(nextIndex(0, 'ArrowRight', 3), 1); + assert.equal(nextIndex(2, 'ArrowRight', 3), 0); + assert.equal(nextIndex(1, 'ArrowLeft', 3), 0); + assert.equal(nextIndex(0, 'ArrowLeft', 3), 2); +}); + +void test('jumps to the first and last slot with Home and End', () => { + assert.equal(nextIndex(2, 'Home', 5), 0); + assert.equal(nextIndex(1, 'End', 5), 4); +}); + +void test('leaves other keys and an empty rail alone', () => { + assert.equal(nextIndex(0, 'Enter', 3), null); + assert.equal(nextIndex(0, 'ArrowDown', 3), null); + assert.equal(nextIndex(0, 'ArrowRight', 0), null); +}); + +void test('opens the card just left of the ring it belongs to', () => { + const placement = cardPlacement({ + slot: { left: 380, width: 56 }, + column: { left: 300, width: 620 }, + }); + + assert.deepEqual(placement, { width: 520, alignOffset: -36, originX: 64 }); +}); + +void test('keeps the card inside the feed column near either edge', () => { + const first = cardPlacement({ + slot: { left: 316, width: 56 }, + column: { left: 300, width: 620 }, + }); + const last = cardPlacement({ + slot: { left: 856, width: 56 }, + column: { left: 300, width: 620 }, + }); + + assert.deepEqual(first, { width: 520, alignOffset: 0, originX: 28 }); + // The card ends 16px inside the column's right edge, and the line still lands under the ring. + assert.deepEqual(last, { width: 520, alignOffset: -472, originX: 500 }); +}); + +void test('narrows the card to the column on a phone', () => { + assert.deepEqual( + cardPlacement({ + slot: { left: 76, width: 56 }, + column: { left: 0, width: 375 }, + }), + { width: 343, alignOffset: -60, originX: 88 }, + ); +}); + +void test('points the line at the card corner when the ring has scrolled out of view', () => { + const placement = cardPlacement({ + slot: { left: -200, width: 56 }, + column: { left: 0, width: 620 }, + }); + + assert.equal(placement.originX, 8); +}); diff --git a/tests/Unit/Services/LivePlatforms/TwitchTest.php b/tests/Unit/Services/LivePlatforms/TwitchTest.php index 2557dafe4..c1a251763 100644 --- a/tests/Unit/Services/LivePlatforms/TwitchTest.php +++ b/tests/Unit/Services/LivePlatforms/TwitchTest.php @@ -8,9 +8,10 @@ $twitch = resolve(Twitch::class); expect($twitch->accepts($url))->toBeTrue() - ->and($twitch->stream($url)->toArray())->toBe([ + ->and($twitch->stream($url, now()->addHour())->toArray())->toMatchArray([ 'url' => 'https://www.twitch.tv/theprimeagen', - 'thumbnail_url' => 'https://static-cdn.jtvnw.net/previews-ttv/live_user_theprimeagen-1280x720.jpg', + 'thumbnail_url' => 'https://static-cdn.jtvnw.net/previews-ttv/live_user_theprimeagen-640x360.jpg', + 'platform' => 'twitch', ]); })->with([ 'channel' => 'https://www.twitch.tv/ThePrimeagen', diff --git a/tests/Unit/Services/LivePlatforms/YouTubeTest.php b/tests/Unit/Services/LivePlatforms/YouTubeTest.php index e0c1d37b8..1123dcf6e 100644 --- a/tests/Unit/Services/LivePlatforms/YouTubeTest.php +++ b/tests/Unit/Services/LivePlatforms/YouTubeTest.php @@ -8,9 +8,10 @@ $youTube = resolve(YouTube::class); expect($youTube->accepts($url))->toBeTrue() - ->and($youTube->stream($url)->toArray())->toBe([ + ->and($youTube->stream($url, now()->addHour())->toArray())->toMatchArray([ 'url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'thumbnail_url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault_live.jpg', + 'platform' => 'youtube', ]); })->with([ 'watch' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s',