From c7c78fc19381c41e3b2aa6780d2f50352cd843a7 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 20:19:21 +0400 Subject: [PATCH 01/21] chore: orbit rail contract and stubs --- resources/js/components/go-live-dialog.tsx | 21 ++++++++ resources/js/components/live/live-avatar.tsx | 36 +++++++++++++ .../js/components/live/live-preview-card.tsx | 23 ++++++++ .../js/components/live/platform-glyph.tsx | 14 +++++ .../js/components/live/stream-player.tsx | 32 +++++++++++ .../components/orbit-rail/orbit-rail-item.tsx | 27 ++++++++++ .../components/orbit-rail/orbit-rail-you.tsx | 15 ++++++ .../js/components/orbit-rail/orbit-rail.tsx | 8 +++ resources/js/hooks/use-hover-intent.ts | 27 ++++++++++ resources/js/hooks/use-live-until.ts | 10 ++++ resources/js/lib/live.ts | 53 +++++++++++++++++++ resources/js/lib/orbit-rail.ts | 35 ++++++++++++ resources/js/types/auth.ts | 6 +-- resources/js/types/global.d.ts | 5 +- resources/js/types/index.ts | 1 + resources/js/types/live.ts | 40 ++++++++++++++ 16 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 resources/js/components/go-live-dialog.tsx create mode 100644 resources/js/components/live/live-avatar.tsx create mode 100644 resources/js/components/live/live-preview-card.tsx create mode 100644 resources/js/components/live/platform-glyph.tsx create mode 100644 resources/js/components/live/stream-player.tsx create mode 100644 resources/js/components/orbit-rail/orbit-rail-item.tsx create mode 100644 resources/js/components/orbit-rail/orbit-rail-you.tsx create mode 100644 resources/js/components/orbit-rail/orbit-rail.tsx create mode 100644 resources/js/hooks/use-hover-intent.ts create mode 100644 resources/js/hooks/use-live-until.ts create mode 100644 resources/js/lib/live.ts create mode 100644 resources/js/lib/orbit-rail.ts create mode 100644 resources/js/types/live.ts diff --git a/resources/js/components/go-live-dialog.tsx b/resources/js/components/go-live-dialog.tsx new file mode 100644 index 000000000..a24f6560d --- /dev/null +++ b/resources/js/components/go-live-dialog.tsx @@ -0,0 +1,21 @@ +/** + * The go-live form (`PUT settings/live`): a stream URL and how many hours to show it as live, from the profile menu + * or the rail's You slot. + */ +export function GoLiveDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return null; +} + +/** + * 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 } { + return { endStream: () => {}, isEnding: false }; +} diff --git a/resources/js/components/live/live-avatar.tsx b/resources/js/components/live/live-avatar.tsx new file mode 100644 index 000000000..df9fea974 --- /dev/null +++ b/resources/js/components/live/live-avatar.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; +import type { Lane, User } from '@/types'; + +/** + * The live look for anything round: a rotating conic ring in the lane's color with a gap before `children`. It + * holds still when `spinning` is off and under reduced motion. + */ +export function LiveRing({ + lane, + spinning = true, + className, + children, +}: { + lane: Lane; + spinning?: boolean; + className?: string; + children: ReactNode; +}) { + return null; +} + +/** + * 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, +}: { + user: Pick; + size: 28 | 36 | 56; + active?: boolean; +}) { + return null; +} 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..9975b5d23 --- /dev/null +++ b/resources/js/components/live/live-preview-card.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; +import type { LiveCommit, LiveStream, User } from '@/types'; + +/** + * 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; +}) { + return null; +} diff --git a/resources/js/components/live/platform-glyph.tsx b/resources/js/components/live/platform-glyph.tsx new file mode 100644 index 000000000..e8d775ad9 --- /dev/null +++ b/resources/js/components/live/platform-glyph.tsx @@ -0,0 +1,14 @@ +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 null; +} diff --git a/resources/js/components/live/stream-player.tsx b/resources/js/components/live/stream-player.tsx new file mode 100644 index 000000000..df8d82b55 --- /dev/null +++ b/resources/js/components/live/stream-player.tsx @@ -0,0 +1,32 @@ +import type { LiveStream, User } from '@/types'; + +/** + * The stream's 16:9 thumbnail with the 5-minute cache-bust from `thumbnailUrl`, so it never shifts layout. + */ +export function StreamThumbnail({ + stream, + className, +}: { + stream: Pick; + className?: string; +}) { + return null; +} + +/** + * 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`. + */ +export function StreamPlayer({ + user, + stream, + isPlaying, + onPlayingChange, +}: { + user: Pick; + stream: LiveStream; + isPlaying: boolean; + onPlayingChange: (isPlaying: boolean) => void; +}) { + return null; +} 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..b2cf583b0 --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail-item.tsx @@ -0,0 +1,27 @@ +import type { PointerEvent, Ref } from 'react'; +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 }) { + return null; +} 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..28b786b65 --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail-you.tsx @@ -0,0 +1,15 @@ +import type { OrbitRailSlotProps } from '@/components/orbit-rail/orbit-rail-item'; +import type { 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; +}) { + return null; +} 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..a1a9994a5 --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail.tsx @@ -0,0 +1,8 @@ +/** + * 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() { + return null; +} diff --git a/resources/js/hooks/use-hover-intent.ts b/resources/js/hooks/use-hover-intent.ts new file mode 100644 index 000000000..4bcabf19e --- /dev/null +++ b/resources/js/hooks/use-hover-intent.ts @@ -0,0 +1,27 @@ +import type { PointerEvent } 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; +} { + return { + onPointerEnter: () => {}, + onPointerLeave: () => {}, + cancel: () => {}, + }; +} diff --git a/resources/js/hooks/use-live-until.ts b/resources/js/hooks/use-live-until.ts new file mode 100644 index 000000000..5a577433c --- /dev/null +++ b/resources/js/hooks/use-live-until.ts @@ -0,0 +1,10 @@ +/** + * When a stream ends, for the viewer's timezone and ticking with `useNow`: `until` like "18:00", `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 { + return null; +} diff --git a/resources/js/lib/live.ts b/resources/js/lib/live.ts new file mode 100644 index 000000000..675e83b67 --- /dev/null +++ b/resources/js/lib/live.ts @@ -0,0 +1,53 @@ +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. + +/** + * 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 { + throw new Error('not implemented'); +} + +/** + * The clock time a stream ends at in a timezone, 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 { + throw new Error('not implemented'); +} + +/** + * 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 { + throw new Error('not implemented'); +} + +/** + * The provider's display name: "YouTube" or "Twitch". + */ +export function platformName(platform: LivePlatform): string { + throw new Error('not implemented'); +} + +/** + * The link-out label in each provider's own words: "Watch on YouTube" or "Join on Twitch". + */ +export function watchLabel(platform: LivePlatform): string { + throw new Error('not implemented'); +} + +/** + * 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 { + throw new Error('not implemented'); +} diff --git a/resources/js/lib/orbit-rail.ts b/resources/js/lib/orbit-rail.ts new file mode 100644 index 000000000..29e8ddb94 --- /dev/null +++ b/resources/js/lib/orbit-rail.ts @@ -0,0 +1,35 @@ +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[] { + throw new Error('not implemented'); +} + +/** + * 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 { + throw new Error('not implemented'); +} + +/** + * The rail's right-hand summary for how many people you follow are live, like "Live now · 6 you follow", and + * "Live now · 1 you follow" for one. With nobody live it reads "Nobody you follow is live". + */ +export function railSummary(count: number): string { + throw new Error('not implemented'); +} 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..c7675522c --- /dev/null +++ b/resources/js/types/live.ts @@ -0,0 +1,40 @@ +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()`: the full user array, always with + * `live` set, plus `latest_commit` (null when they have none). Sorted soonest-ending first, at most 24. + */ +export type LiveFollowingUser = User & { + live: LiveStream; + latest_commit: LiveCommit | null; +}; From af6bcdc3cd0089d4b3ee8538ef44f8a476885230 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 20:26:14 +0400 Subject: [PATCH 02/21] feat: share the go-live dialog, ring the aside live list, document the Orbit Rail Extract the go-live Dialog and End stream into GoLiveDialog and useEndStream so the profile menu and the rail's You slot share one implementation. The aside's Live now list leaves log pages to the rail, shows when each stream ends, and uses LiveAvatar. KNOWLEDGE_BASE, PRODUCT and DESIGN describe inline playback, Twitch, the live ring and the Orbit Rail. --- DESIGN.md | 11 +- KNOWLEDGE_BASE.md | 8 +- PRODUCT.md | 3 +- resources/js/components/go-live-dialog.tsx | 137 +++++++++++++++++- resources/js/components/live-following.tsx | 87 ++++++----- .../js/components/profile-actions-menu.tsx | 132 +---------------- 6 files changed, 206 insertions(+), 172 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a57816e58..2d9a1239b 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 streaming has one look everywhere (logs, profiles, previews, the aside, the Orbit Rail): their avatar inside a rotating 2px conic ring in their lane, a 2px gap from the avatar, with the red mono LIVE pill on the ring's bottom edge. One component (`LiveAvatar`) draws it; under reduced motion the ring holds still. No plain red rings, 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,6 +325,11 @@ 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 right end reads "Live now · 6 you follow" ("Nobody you follow is live" when empty). 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. 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. + ### 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. diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 894c7a17c..c16bae43e 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, and in hover cards. Hovering it (or tapping it on a touch screen) shows the stream's live thumbnail with a **Watch** button. 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. On your profile, the avatar links straight to the stream. + +Signed in, every log 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 with the stream's thumbnail, their latest commit, when the stream ends in your time zone, and links to watch it and to their profile. The rail refreshes with the log every 30 seconds and drops streams as they end. Other pages list the same people in the side column's **Live now** box, with when each stream ends; logs leave that box out, since the rail already shows them. **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/resources/js/components/go-live-dialog.tsx b/resources/js/components/go-live-dialog.tsx index a24f6560d..a0abcfeb2 100644 --- a/resources/js/components/go-live-dialog.tsx +++ b/resources/js/components/go-live-dialog.tsx @@ -1,3 +1,26 @@ +import { Form, router } from '@inertiajs/react'; +import { Radio } from 'lucide-react'; +import { useState } from 'react'; +import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; + +/** + * Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live. + */ +const maxHours = 12; + +const defaultHours = 2; + /** * The go-live form (`PUT settings/live`): a stream URL and how many hours to show it as live, from the profile menu * or the rail's You slot. @@ -9,7 +32,107 @@ export function GoLiveDialog({ open: boolean; onOpenChange: (open: boolean) => void; }) { - return null; + 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 +
+ +
+ + + + + + + +
+ )} +
+
+
+ ); } /** @@ -17,5 +140,15 @@ export function GoLiveDialog({ * while the request runs. */ export function useEndStream(): { endStream: () => void; isEnding: boolean } { - return { endStream: () => {}, isEnding: false }; + 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/live-following.tsx b/resources/js/components/live-following.tsx index c693e1b8e..bb2f38d33 100644 --- a/resources/js/components/live-following.tsx +++ b/resources/js/components/live-following.tsx @@ -1,18 +1,24 @@ import { usePage } from '@inertiajs/react'; import { ArrowUpRight } from 'lucide-react'; -import { LiveAvatarMarker } from '@/components/live-stream-card'; -import { UserAvatar } from '@/components/user-avatar'; +import { LiveAvatar } from '@/components/live/live-avatar'; +import { useLiveUntil } from '@/hooks/use-live-until'; +import type { LiveFollowingUser } from '@/types'; +/** + * The aside's "Live now" list, soonest-ending first as the server sends it. Log pages leave it out because the + * Orbit Rail above the feed already shows the same people. + */ export function LiveFollowing() { - const { liveFollowing = [] } = usePage().props; - const users = liveFollowing.filter((user) => user.live); + const { component, props } = usePage(); + const users = props.liveFollowing ?? []; - if (users.length === 0) return null; + if (component === 'log/show' || users.length === 0) return null; + // Rows drop themselves once their stream ends, and the whole box goes with the last one. return (

); } + +function LiveFollowingItem({ user }: { user: LiveFollowingUser }) { + const live = useLiveUntil(user.live.ends_at); + + if (live?.isOver) return null; + + return ( +
  • + + + + + + + {user.name} + + + @{user.username} + {live && ` · until ${live.until}`} + + + + +
  • + ); +} 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 -
    - -
    - - - - - - - -
    - )} -
    -
    -
    + ); } From 6e40aac5e3255e4e8b2306e6230e4b3691e7cda3 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 20:40:05 +0400 Subject: [PATCH 03/21] feat: reusable live primitives and the rotating lane ring Live helpers, hover intent and SSR-safe live-until hooks, platform glyph, LiveRing/LiveAvatar, StreamThumbnail/StreamPlayer with play-to-load embeds, and LivePreviewCard. LiveStreamCard, LiveStreamTrigger and the generic LiveStreamSheet now build on them, and the feed node, user preview and profile header wear the rotating lane ring instead of the red one. --- resources/css/app.css | 20 ++ resources/js/components/graph-rail.tsx | 109 +++++----- resources/js/components/live-stream-card.tsx | 197 +++++++++--------- resources/js/components/live/live-avatar.tsx | 93 ++++++++- .../js/components/live/live-preview-card.tsx | 163 ++++++++++++++- .../js/components/live/platform-glyph.tsx | 28 ++- .../js/components/live/stream-player.tsx | 125 ++++++++++- resources/js/components/user-preview.tsx | 31 +-- resources/js/hooks/use-hover-intent.ts | 33 ++- resources/js/hooks/use-live-until.ts | 24 ++- resources/js/lib/live.ts | 48 ++++- resources/js/pages/user/show.tsx | 19 +- tests/Unit/Live.test.ts | 74 +++++++ 13 files changed, 762 insertions(+), 202 deletions(-) create mode 100644 tests/Unit/Live.test.ts diff --git a/resources/css/app.css b/resources/css/app.css index 1f6283a27..8caf8b965 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,6 +8,18 @@ @custom-variant dark (&:is(.dark *)); @custom-variant in-place (&:is(:root[data-view-transition] *)); +/* The live ring's stroke: a disc painted with the conic `--live-ring` and hollowed out by a mask, so the gap before the + avatar shows whatever surface it sits on. The mask is round, so the ring can rotate as a whole on the compositor. */ +@utility live-ring { + border-radius: calc(infinity * 1px); + background: var(--live-ring); + mask: radial-gradient( + farthest-side, + transparent calc(100% - var(--live-ring-width, 2px) - 0.5px), + black calc(100% - var(--live-ring-width, 2px)) + ); +} + /* Keep the fingerprint's wrapping, selection and accessible text while revealing its tiny secret. */ .certificate-hash { position: relative; @@ -257,6 +269,14 @@ --animate-commit-in: commit-in 300ms linear both; --animate-star-pop: star-pop 450ms cubic-bezier(0.175, 0.885, 0.32, 1.275) both; + /* Whoever is live turns slowly enough to read as "on air" rather than "loading". */ + --animate-live-ring: live-ring 9s linear infinite; + + @keyframes live-ring { + to { + transform: rotate(360deg); + } + } @keyframes star-pop { 0% { diff --git a/resources/js/components/graph-rail.tsx b/resources/js/components/graph-rail.tsx index c87095d6a..edfc8e83b 100644 --- a/resources/js/components/graph-rail.tsx +++ b/resources/js/components/graph-rail.tsx @@ -9,6 +9,7 @@ import { } from '@/components/graph-canvas'; import type { GraphNodeSpec } from '@/components/graph-canvas'; import { LiveAvatarMarker } from '@/components/live-stream-card'; +import { LiveRing } from '@/components/live/live-avatar'; import { UserAvatar } from '@/components/user-avatar'; import { bridgeDuration } from '@/lib/graph'; import { cn, laneStrokeStyle } from '@/lib/utils'; @@ -373,7 +374,7 @@ export function RailAvatar({ user, progress, }: { - user: Pick; + 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; }) { @@ -381,65 +382,63 @@ export function RailAvatar({ return ( - - {progress !== undefined && ( - <> - 0 ? 'opacity-0' : 'opacity-100', - )} - /> - 0 ? 'opacity-100' : 'opacity-0', - )} - fill="none" - strokeWidth={2} - > - + + + ) : ( + + {progress !== undefined && ( + <> + 0 ? 'opacity-0' : 'opacity-100', )} /> - 0 ? 'opacity-100' : 'opacity-0', )} - /> - - - )} - - + fill="none" + strokeWidth={2} + > + + + + + )} + + + )} {isLive && ( )} diff --git a/resources/js/components/live-stream-card.tsx b/resources/js/components/live-stream-card.tsx index ddd292cc3..0e1c3e0d3 100644 --- a/resources/js/components/live-stream-card.tsx +++ b/resources/js/components/live-stream-card.tsx @@ -2,8 +2,12 @@ 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 { useState } from 'react'; import { LiveBadge } from '@/components/live-badge'; +import { LiveAvatar } from '@/components/live/live-avatar'; +import { LiveUntil } from '@/components/live/live-preview-card'; +import { PlatformGlyph } from '@/components/live/platform-glyph'; +import { StreamPlayer } from '@/components/live/stream-player'; import { Button } from '@/components/ui/button'; import { DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { @@ -11,48 +15,49 @@ import { PopoverAnchor, PopoverContent, } from '@/components/ui/popover'; -import { UserAvatar } from '@/components/user-avatar'; -import { hostname } from '@/lib/urls'; +import { useHoverIntent } from '@/hooks/use-hover-intent'; +import { platformName, watchLabel } from '@/lib/live'; import { cn } from '@/lib/utils'; 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] = useState(false); + return (
    - - - - + { + setIsPlaying(next); + onPlayingChange?.(next); + }} + />

    {user.name} is live

    -

    - {hostname(stream.url)} +

    + + {platformName(stream.platform)}

    + @@ -69,7 +74,8 @@ export function LiveStreamCard({ } /** - * The avatar's ring turns red and a LIVE label sits on its edge, sized to the avatar it marks. + * The LIVE label on the bottom edge of an avatar's live ring, for avatars that draw their own ring. `LiveAvatar` + * already carries it. */ export function LiveAvatarMarker({ className }: { className?: string }) { return ( @@ -84,8 +90,8 @@ export function LiveAvatarMarker({ className }: { className?: string }) { /** * 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 +100,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 +124,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 +159,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 +225,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 index df9fea974..45deece24 100644 --- a/resources/js/components/live/live-avatar.tsx +++ b/resources/js/components/live/live-avatar.tsx @@ -1,36 +1,117 @@ -import type { ReactNode } from 'react'; +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 when `spinning` is off and under reduced motion. + * holds still when `spinning` is off and 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. */ export function LiveRing({ lane, spinning = true, + isGlowing = false, className, children, }: { lane: Lane; spinning?: boolean; + isGlowing?: boolean; className?: string; children: ReactNode; }) { - return null; + const motion = spinning && 'motion-safe:animate-live-ring'; + + return ( + + {isGlowing && ( + + )} + + {children} + + ); } +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; + /** * 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. + * `active` marks the one whose stream card is open. The pill is cut out of the ring in the `surface` it sits on. */ export function LiveAvatar({ user, size, active = false, + surface = 'card', }: { user: Pick; - size: 28 | 36 | 56; + size: keyof typeof sizes; active?: boolean; + surface?: 'card' | 'popover'; }) { - return null; + return ( + + + + + + + ); } diff --git a/resources/js/components/live/live-preview-card.tsx b/resources/js/components/live/live-preview-card.tsx index 9975b5d23..ade4c4fcb 100644 --- a/resources/js/components/live/live-preview-card.tsx +++ b/resources/js/components/live/live-preview-card.tsx @@ -1,6 +1,110 @@ -import type { ReactNode } from 'react'; +import { Link } from '@inertiajs/react'; +import { ArrowUpRight } from 'lucide-react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useState } from 'react'; +import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import { PlatformGlyph } from '@/components/live/platform-glyph'; +import { StreamPlayer } from '@/components/live/stream-player'; +import { Button } from '@/components/ui/button'; +import { useLiveUntil } from '@/hooks/use-live-until'; +import { useNow } from '@/hooks/use-now'; +import { platformName, watchLabel } 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' + ) : ( + <> + live until{' '} + {' '} + · ends in {live.endsIn} + + )} +

    + ); +} + +/** + * Plays one stream at a time: which one is remembered by URL, so showing someone else's stream starts it paused. + */ +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); + }, + ]; +} + +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 @@ -19,5 +123,60 @@ export function LivePreviewCard({ actions?: ReactNode; onPlayingChange?: (isPlaying: boolean) => void; }) { - return null; + const [isPlaying, setIsPlaying] = usePlayingStream(stream, onPlayingChange); + + return ( +
    +
    + + +
    +
    + + {user.name} + + + @{user.username} + + + + {platformName(stream.platform)} + +
    + + {latestCommit && ( + + )} + + + + +
    +
    +
    + ); } diff --git a/resources/js/components/live/platform-glyph.tsx b/resources/js/components/live/platform-glyph.tsx index e8d775ad9..6213a3853 100644 --- a/resources/js/components/live/platform-glyph.tsx +++ b/resources/js/components/live/platform-glyph.tsx @@ -1,3 +1,4 @@ +import { cn } from '@/lib/utils'; import type { LivePlatform } from '@/types'; /** @@ -10,5 +11,30 @@ export function PlatformGlyph({ platform: LivePlatform; className?: string; }) { - return null; + return ( + + {platform === 'youtube' ? ( + <> + + + + ) : ( + <> + + + + )} + + ); } diff --git a/resources/js/components/live/stream-player.tsx b/resources/js/components/live/stream-player.tsx index df8d82b55..c565a5945 100644 --- a/resources/js/components/live/stream-player.tsx +++ b/resources/js/components/live/stream-player.tsx @@ -1,7 +1,48 @@ +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 { useNow } from '@/hooks/use-now'; +import { platformName, thumbnailUrl } 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 ( + 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 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. */ export function StreamThumbnail({ stream, @@ -10,12 +51,30 @@ export function StreamThumbnail({ stream: Pick; className?: string; }) { - return null; + const isClient = useSyncExternalStore( + subscribe, + () => true, + () => false, + ); + const now = useNow(); + const src = thumbnailUrl(stream, now); + + return ( + + {isClient && } + + ); } /** * 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`. + * no `stream.embed` it only links out to `stream.url`. Nothing from the provider loads until play is pressed. */ export function StreamPlayer({ user, @@ -28,5 +87,63 @@ export function StreamPlayer({ isPlaying: boolean; onPlayingChange: (isPlaying: boolean) => void; }) { - return null; + 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/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 index 4bcabf19e..612d6f530 100644 --- a/resources/js/hooks/use-hover-intent.ts +++ b/resources/js/hooks/use-hover-intent.ts @@ -1,4 +1,5 @@ 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 @@ -19,9 +20,35 @@ export function useHoverIntent({ 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: () => {}, - onPointerLeave: () => {}, - cancel: () => {}, + 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 index 5a577433c..dd95ecf75 100644 --- a/resources/js/hooks/use-live-until.ts +++ b/resources/js/hooks/use-live-until.ts @@ -1,3 +1,10 @@ +import { useSyncExternalStore } from 'react'; +import { useNow } from '@/hooks/use-now'; +import { formatEndsIn, formatLiveUntil, isStreaming } from '@/lib/live'; +import { browserTimeZone } from '@/lib/utils'; + +const subscribe = () => () => {}; + /** * When a stream ends, for the viewer's timezone and ticking with `useNow`: `until` like "18:00", `endsIn` like * "1h 08m", and `isOver` once `endsAt` has passed. Null during SSR and the first render, so server and client @@ -6,5 +13,20 @@ export function useLiveUntil( endsAt: string, ): { until: string; endsIn: string; isOver: boolean } | null { - return null; + const isClient = useSyncExternalStore( + subscribe, + () => true, + () => false, + ); + const now = useNow(); + + if (!isClient) { + return null; + } + + return { + until: formatLiveUntil(endsAt, browserTimeZone()), + endsIn: formatEndsIn(endsAt, now), + isOver: !isStreaming({ ends_at: endsAt }, now), + }; } diff --git a/resources/js/lib/live.ts b/resources/js/lib/live.ts index 675e83b67..d0f901a14 100644 --- a/resources/js/lib/live.ts +++ b/resources/js/lib/live.ts @@ -2,6 +2,19 @@ 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. */ @@ -9,7 +22,7 @@ export function isStreaming( stream: Pick, now: Date, ): boolean { - throw new Error('not implemented'); + return new Date(stream.ends_at).getTime() > now.getTime(); } /** @@ -17,28 +30,40 @@ export function isStreaming( * as `formatZonedTime`. */ export function formatLiveUntil(endsAt: string, timeZone: string): string { - throw new Error('not implemented'); + return new Date(endsAt).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + timeZone, + }); } /** * 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 { - throw new Error('not implemented'); + 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 { - throw new Error('not implemented'); + 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 { - throw new Error('not implemented'); + return watchLabels[platform]; } /** @@ -49,5 +74,16 @@ export function thumbnailUrl( stream: Pick, now: Date, ): string { - throw new Error('not implemented'); + 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/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/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'); +}); From c12d8fecca2bc21bafaca9835313861eb049c4fe Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 20:40:06 +0400 Subject: [PATCH 04/21] docs: live ring rule in DESIGN.md --- DESIGN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index a57816e58..07bc9097b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -210,6 +210,8 @@ A cool, nearly colorless shell with one working blue and six author lanes reserv **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. +**The Live Ring Rule.** Someone who is live wears one look everywhere (feed node, profile header, user preview, aside, the Orbit Rail): the `LiveRing` from `components/live/live-avatar.tsx`, 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. It turns once every 9s, by rotation only, and holds still under reduced motion. No other avatar ring spins, and live is never shown as a red avatar ring. + ## Typography **Display Font:** Geist (with ui-sans-serif, system-ui) From 57cc66284b7658b65c4e82d4337562bab584c5d3 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 20:44:26 +0400 Subject: [PATCH 05/21] feat: orbit rail on the log pages The live-now rail between the log tabs and the composer: you first, then the people you follow who are live, with one floating stream card anchored to whichever ring is open, a sheet on touch, and liveFollowing in the log poll. --- .../components/orbit-rail/orbit-rail-card.tsx | 109 +++ .../components/orbit-rail/orbit-rail-item.tsx | 90 ++- .../components/orbit-rail/orbit-rail-you.tsx | 78 ++- .../js/components/orbit-rail/orbit-rail.tsx | 624 +++++++++++++++++- resources/js/lib/orbit-rail.ts | 66 +- resources/js/pages/log/show.tsx | 5 +- tests/Unit/OrbitRail.test.ts | 95 +++ 7 files changed, 1058 insertions(+), 9 deletions(-) create mode 100644 resources/js/components/orbit-rail/orbit-rail-card.tsx create mode 100644 tests/Unit/OrbitRail.test.ts 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..baa9feda7 --- /dev/null +++ b/resources/js/components/orbit-rail/orbit-rail-card.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +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. + const [bloomX] = useState(originX); + + useLayoutEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return; + } + + const animations = [ + card.current?.animate( + [ + { + clipPath: `circle(0px at ${bloomX}px 0px)`, + opacity: 0.3, + transform: 'translateY(-8px) scale(0.985)', + }, + { + clipPath: `circle(160% at ${bloomX}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', + }, + ), + ]; + + return () => animations.forEach((animation) => animation?.cancel()); + }, [bloomX]); + + 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 index b2cf583b0..a308d1c25 100644 --- a/resources/js/components/orbit-rail/orbit-rail-item.tsx +++ b/resources/js/components/orbit-rail/orbit-rail-item.tsx @@ -1,4 +1,8 @@ -import type { PointerEvent, Ref } from 'react'; +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'; /** @@ -23,5 +27,87 @@ export function OrbitRailItem({ user, ...slot }: OrbitRailSlotProps & { user: LiveFollowingUser }) { - return null; + 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 index 28b786b65..5b1e9da03 100644 --- a/resources/js/components/orbit-rail/orbit-rail-you.tsx +++ b/resources/js/components/orbit-rail/orbit-rail-you.tsx @@ -1,5 +1,12 @@ +import { Plus } from 'lucide-react'; +import { LiveAvatar } from '@/components/live/live-avatar'; import type { OrbitRailSlotProps } from '@/components/orbit-rail/orbit-rail-item'; -import type { Profile } from '@/types'; +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 @@ -11,5 +18,72 @@ export function OrbitRailYou({ }: OrbitRailSlotProps & { user: Pick; }) { - return null; + 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 index a1a9994a5..107fa204c 100644 --- a/resources/js/components/orbit-rail/orbit-rail.tsx +++ b/resources/js/components/orbit-rail/orbit-rail.tsx @@ -1,8 +1,630 @@ +import { Link, usePage } from '@inertiajs/react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { Square } from 'lucide-react'; +import type { KeyboardEvent, PointerEvent } from 'react'; +import { + useCallback, + useEffect, + useId, + 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 { useNow } from '@/hooks/use-now'; +import { isStreaming } from '@/lib/live'; +import type { CardPlacement } from '@/lib/orbit-rail'; +import { + cardPlacement, + nextIndex, + railSummary, + 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() { - return null; + const { + auth: { user }, + liveFollowing = [], + } = usePage().props; + + if (user === null) { + return null; + } + + return ; +} + +function Rail({ + me, + liveFollowing, +}: { + me: Profile; + liveFollowing: LiveFollowingUser[]; +}) { + 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 titleId = useId(); + 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); + }, []); + + // 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)) { + setCard({ + id, + pinned: false, + focusRequest: null, + placement: place(id), + }); + } + }, + [canPreview, place], + ); + 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 + ) { + setCard({ + ...open, + id, + focusRequest: null, + placement: place(id), + }); + } + + 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; + } + + setCard({ + id, + pinned: true, + focusRequest: (open?.focusRequest ?? 0) + 1, + placement: place(id), + }); + }, + onFocus: () => setFocusIndex(index), + }, + ]), + ), + [ids, slots, canPreview, close, place], + ); + + 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) => ( + + ))} +
    +
    +
    + +

    + {people.length > 0 && ( + + + + + )} + {railSummary(people.length)} +

    + + !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 through the ring centres, with a pulse of the open card's lane travelling along it. + */ +function LaneLine({ color }: { color: string }) { + const traveller = useRef(null); + + useEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return; + } + + // The traveller spans the whole line with the pulse at its end, so -100% to +pulse crosses any rail length. + const animation = traveller.current?.animate( + { transform: ['translateX(-100%)', 'translateX(120px)'] }, + { duration: 5500, iterations: Infinity, easing: 'linear' }, + ); + + return () => animation?.cancel(); + }, []); + + 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/lib/orbit-rail.ts b/resources/js/lib/orbit-rail.ts index 29e8ddb94..50ab1c3d6 100644 --- a/resources/js/lib/orbit-rail.ts +++ b/resources/js/lib/orbit-rail.ts @@ -10,7 +10,9 @@ export function visibleLive }>( users: readonly T[], now: Date, ): T[] { - throw new Error('not implemented'); + const time = now.getTime(); + + return users.filter((user) => new Date(user.live.ends_at).getTime() > time); } /** @@ -23,7 +25,22 @@ export function nextIndex( key: string, count: number, ): number | null { - throw new Error('not implemented'); + 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; + } } /** @@ -31,5 +48,48 @@ export function nextIndex( * "Live now · 1 you follow" for one. With nobody live it reads "Nobody you follow is live". */ export function railSummary(count: number): string { - throw new Error('not implemented'); + return count === 0 + ? 'Nobody you follow is live' + : `Live now · ${count} you follow`; +} + +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..0f5307fd1 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'; @@ -386,7 +387,7 @@ export default function Show({ const { stop: stopPolling } = usePoll( 30_000, backgroundPollOptions(() => ({ - only: ['newCommitsCount', 'removedCommitIds'], + only: ['newCommitsCount', 'removedCommitIds', 'liveFollowing'], data: { since: newestId, pending: notice?.leaving @@ -550,6 +551,8 @@ export default function Show({ />
    + {user !== null && } + {user !== null && (
    diff --git a/tests/Unit/OrbitRail.test.ts b/tests/Unit/OrbitRail.test.ts new file mode 100644 index 000000000..4a73e1c08 --- /dev/null +++ b/tests/Unit/OrbitRail.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + cardPlacement, + nextIndex, + railSummary, + 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('says how many people you follow are live', () => { + assert.equal(railSummary(0), 'Nobody you follow is live'); + assert.equal(railSummary(1), 'Live now · 1 you follow'); + assert.equal(railSummary(6), 'Live now · 6 you follow'); +}); + +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); +}); From 3f6fa57f3eec6193f2407c4ea3aea89ba221142d Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 20:54:02 +0400 Subject: [PATCH 06/21] perf: index users by when their stream ends Only people who are live have an end time, so a partial index keeps the live list and the job that ends streams off the rest of the users table. --- ..._add_live_ends_at_index_to_users_table.php | 20 ++++++++ tests/Feature/LiveFollowingQueryTest.php | 51 ------------------- 2 files changed, 20 insertions(+), 51 deletions(-) create mode 100644 database/migrations/2026_10_24_140000_add_live_ends_at_index_to_users_table.php delete mode 100644 tests/Feature/LiveFollowingQueryTest.php 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 @@ +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(); -}); From c6c29b23bacca63c02644f418726c6d1865c225e Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 21:03:09 +0400 Subject: [PATCH 07/21] feat: share who is live with their stream, player and latest commit The live stream now says its platform, when it ends and the player it plays inline with, which stays null when embeds are off. Live follows come soonest ending first, up to 24 for the orbit rail, each with their newest approved top-level commit, in two queries however many are live. Guests no longer get the prop at all. --- app/Actions/UpdateUserLive.php | 6 +- app/Contracts/Services/LivePlatformDriver.php | 5 +- app/Dto/LiveStream.php | 15 +- app/Http/Middleware/HandleInertiaRequests.php | 7 +- app/Models/User.php | 6 +- app/Queries/LiveFollowingQuery.php | 59 ++++++- app/Services/LivePlatforms/Twitch.php | 14 +- app/Services/LivePlatforms/YouTube.php | 14 +- database/factories/UserFactory.php | 17 ++ .../Controllers/UserLiveControllerTest.php | 40 ++++- tests/Feature/LiveFollowingTest.php | 159 ++++++++++++++++++ .../Middleware/HandleInertiaRequestsTest.php | 10 +- .../Services/LivePlatforms/TwitchTest.php | 3 +- .../Services/LivePlatforms/YouTubeTest.php | 3 +- 14 files changed, 317 insertions(+), 41 deletions(-) create mode 100644 tests/Feature/LiveFollowingTest.php 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..0a986c62d 100644 --- a/app/Queries/LiveFollowingQuery.php +++ b/app/Queries/LiveFollowingQuery.php @@ -4,24 +4,65 @@ 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. + * + * @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() + ->makeHidden('latest_commit_id'); + + $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 [ + ...$person->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/LivePlatforms/Twitch.php b/app/Services/LivePlatforms/Twitch.php index d7df45ed6..9fe78c0c4 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) { // } @@ -25,11 +31,13 @@ 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. */ - 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}-1280x720.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/tests/Feature/Controllers/UserLiveControllerTest.php b/tests/Feature/Controllers/UserLiveControllerTest.php index a8b683713..0df8984de 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', @@ -56,6 +62,16 @@ function liveUser(): User ->where('user.live', [ 'url' => 'https://www.twitch.tv/octocat', 'thumbnail_url' => 'https://static-cdn.jtvnw.net/previews-ttv/live_user_octocat-1280x720.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/LiveFollowingTest.php b/tests/Feature/LiveFollowingTest.php new file mode 100644 index 000000000..b880e9d5c --- /dev/null +++ b/tests/Feature/LiveFollowingTest.php @@ -0,0 +1,159 @@ +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 as a user with 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-1280x720.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'); +}); + +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/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/Services/LivePlatforms/TwitchTest.php b/tests/Unit/Services/LivePlatforms/TwitchTest.php index 2557dafe4..3e01f55a1 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', + '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', From 9d121f22bdc534bfa6603f19eb0e2774b20f9aba Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 21:39:51 +0400 Subject: [PATCH 08/21] fix: orbit rail refreshes on every log tab and shares its live pieces - The rail polls liveFollowing itself every 30s (paused in hidden tabs), so Trending, whose page poll never starts, stays current; the log page's poll no longer asks for it twice. - The aside's Live now list shows the five streams ending soonest. - One LIVE pill placement (LiveAvatarBadge) for LiveAvatar and the feed node, replacing LiveAvatarMarker; feed nodes draw LiveAvatar. - LiveStreamCard and LivePreviewCard share usePlayingStream and WatchStreamButton; StreamThumbnail is private to the player. - The card's bloom and the rail's lane pulse share useMountAnimations. - Thumbnails load eagerly: a lazy image inside the bloom's clipped start stayed unloaded. - Card copy no longer splits "ends in 10h 49m", and the handle truncates before the name. --- resources/js/components/graph-rail.tsx | 117 +++++++++--------- resources/js/components/live-badge.tsx | 4 +- resources/js/components/live-following.tsx | 18 +-- resources/js/components/live-stream-card.tsx | 45 ++----- resources/js/components/live/live-avatar.tsx | 34 +++-- .../js/components/live/live-preview-card.tsx | 61 +++------ .../js/components/live/stream-player.tsx | 52 +++++++- .../components/orbit-rail/orbit-rail-card.tsx | 69 +++++------ .../js/components/orbit-rail/orbit-rail.tsx | 56 ++++----- resources/js/hooks/use-mount-animations.ts | 22 ++++ resources/js/pages/log/show.tsx | 2 +- 11 files changed, 258 insertions(+), 222 deletions(-) create mode 100644 resources/js/hooks/use-mount-animations.ts diff --git a/resources/js/components/graph-rail.tsx b/resources/js/components/graph-rail.tsx index edfc8e83b..03b56c617 100644 --- a/resources/js/components/graph-rail.tsx +++ b/resources/js/components/graph-rail.tsx @@ -8,8 +8,7 @@ import { useShowsUpLive, } from '@/components/graph-canvas'; import type { GraphNodeSpec } from '@/components/graph-canvas'; -import { LiveAvatarMarker } from '@/components/live-stream-card'; -import { LiveRing } from '@/components/live/live-avatar'; +import { LiveAvatar, LiveAvatarBadge } from '@/components/live/live-avatar'; import { UserAvatar } from '@/components/user-avatar'; import { bridgeDuration } from '@/lib/graph'; import { cn, laneStrokeStyle } from '@/lib/utils'; @@ -380,68 +379,70 @@ export function RailAvatar({ }) { const isLive = Boolean(user.live); - return ( - - {isLive && progress === undefined ? ( - - - - ) : ( + if (isLive && progress === undefined) { + return ( + - {progress !== undefined && ( - <> - 0 ? 'opacity-0' : 'opacity-100', - )} - /> - 0 ? 'opacity-100' : 'opacity-0', - )} - fill="none" - strokeWidth={2} - > - - - - - )} - + - )} - {isLive && ( - - )} + + ); + } + + return ( + + + {progress !== undefined && ( + <> + 0 ? 'opacity-0' : 'opacity-100', + )} + /> + 0 ? 'opacity-100' : 'opacity-0', + )} + fill="none" + strokeWidth={2} + > + + + + + )} + + + {/* 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 (

    void; }) { - const [isPlaying, setIsPlaying] = useState(false); + const [isPlaying, setIsPlaying] = usePlayingStream(stream, onPlayingChange); return ( ); } -/** - * The LIVE label on the bottom edge of an avatar's live ring, for avatars that draw their own ring. `LiveAvatar` - * already carries it. - */ -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; pressing play pins that card open until it is dismissed. On touch, diff --git a/resources/js/components/live/live-avatar.tsx b/resources/js/components/live/live-avatar.tsx index 45deece24..fc00e402f 100644 --- a/resources/js/components/live/live-avatar.tsx +++ b/resources/js/components/live/live-avatar.tsx @@ -81,9 +81,33 @@ const sizes = { 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. The pill is cut out of the ring in the `surface` it sits on. + * `active` marks the one whose stream card is open. */ export function LiveAvatar({ user, @@ -105,13 +129,7 @@ export function LiveAvatar({ pixels={size * 2} /> - + ); } diff --git a/resources/js/components/live/live-preview-card.tsx b/resources/js/components/live/live-preview-card.tsx index ade4c4fcb..4d1d0c476 100644 --- a/resources/js/components/live/live-preview-card.tsx +++ b/resources/js/components/live/live-preview-card.tsx @@ -1,14 +1,15 @@ import { Link } from '@inertiajs/react'; -import { ArrowUpRight } from 'lucide-react'; import type { CSSProperties, ReactNode } from 'react'; -import { useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import { PlatformGlyph } from '@/components/live/platform-glyph'; -import { StreamPlayer } from '@/components/live/stream-player'; -import { Button } from '@/components/ui/button'; +import { + StreamPlayer, + usePlayingStream, + WatchStreamButton, +} from '@/components/live/stream-player'; import { useLiveUntil } from '@/hooks/use-live-until'; import { useNow } from '@/hooks/use-now'; -import { platformName, watchLabel } from '@/lib/live'; +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'; @@ -39,36 +40,23 @@ export function LiveUntil({ ) : 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} + + live until{' '} + + {' '} + + · ends in {live.endsIn} + )}

    ); } -/** - * Plays one stream at a time: which one is remembered by URL, so showing someone else's stream starts it paused. - */ -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); - }, - ]; -} - function LatestCommit({ user, commit, @@ -137,13 +125,14 @@ export function LivePreviewCard({
    + {/* The handle gives way first, so the name stays readable next to it. */} {user.name} - + @{user.username} @@ -162,17 +151,7 @@ export function LivePreviewCard({
    diff --git a/resources/js/components/live/stream-player.tsx b/resources/js/components/live/stream-player.tsx index c565a5945..c4fef12b1 100644 --- a/resources/js/components/live/stream-player.tsx +++ b/resources/js/components/live/stream-player.tsx @@ -2,8 +2,9 @@ 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 } from '@/lib/live'; +import { platformName, thumbnailUrl, watchLabel } from '@/lib/live'; import { cn } from '@/lib/utils'; import type { LiveStream, User } from '@/types'; @@ -28,10 +29,11 @@ function ThumbnailImage({ src }: { src: string }) { } 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)} @@ -44,7 +46,7 @@ function ThumbnailImage({ src }: { src: string }) { * 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. */ -export function StreamThumbnail({ +function StreamThumbnail({ stream, className, }: { @@ -72,6 +74,50 @@ export function StreamThumbnail({ ); } +/** + * 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. diff --git a/resources/js/components/orbit-rail/orbit-rail-card.tsx b/resources/js/components/orbit-rail/orbit-rail-card.tsx index baa9feda7..c3a481113 100644 --- a/resources/js/components/orbit-rail/orbit-rail-card.tsx +++ b/resources/js/components/orbit-rail/orbit-rail-card.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; -import { useEffect, useLayoutEffect, useRef, useState } 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"])'; @@ -24,46 +25,36 @@ export function OrbitRailCard({ 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. - const [bloomX] = useState(originX); - - useLayoutEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - return; - } - - const animations = [ - card.current?.animate( - [ - { - clipPath: `circle(0px at ${bloomX}px 0px)`, - opacity: 0.3, - transform: 'translateY(-8px) scale(0.985)', - }, - { - clipPath: `circle(160% at ${bloomX}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)'] }, + useMountAnimations(() => [ + card.current?.animate( + [ { - duration: 500, - delay: 120, - easing: 'cubic-bezier(0.16, 1, 0.3, 1)', - fill: 'backwards', + clipPath: `circle(0px at ${originX}px 0px)`, + opacity: 0.3, + transform: 'translateY(-8px) scale(0.985)', }, - ), - ]; - - return () => animations.forEach((animation) => animation?.cancel()); - }, [bloomX]); + { + 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) { diff --git a/resources/js/components/orbit-rail/orbit-rail.tsx b/resources/js/components/orbit-rail/orbit-rail.tsx index 107fa204c..feb3c88c5 100644 --- a/resources/js/components/orbit-rail/orbit-rail.tsx +++ b/resources/js/components/orbit-rail/orbit-rail.tsx @@ -1,10 +1,9 @@ -import { Link, usePage } from '@inertiajs/react'; +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, - useEffect, useId, useLayoutEffect, useMemo, @@ -20,7 +19,9 @@ 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 { @@ -79,6 +80,13 @@ function Rail({ 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( @@ -163,6 +171,13 @@ function Rail({ 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) => { @@ -177,15 +192,10 @@ function Rail({ const id = hovered.current; if (latest.current.card === null && id && canPreview(id)) { - setCard({ - id, - pinned: false, - focusRequest: null, - placement: place(id), - }); + openCard(id, { pinned: false, focusRequest: null }); } }, - [canPreview, place], + [canPreview, openCard], ); const hover = useHoverIntent({ onChange: onHoverChange }); const hoverRef = useRef(hover); @@ -226,11 +236,9 @@ function Rail({ open.id !== id && !isPlaying.current ) { - setCard({ - ...open, - id, + openCard(id, { + pinned: open.pinned, focusRequest: null, - placement: place(id), }); } @@ -276,18 +284,16 @@ function Rail({ isPlaying.current = false; } - setCard({ - id, + openCard(id, { pinned: true, focusRequest: (open?.focusRequest ?? 0) + 1, - placement: place(id), }); }, onFocus: () => setFocusIndex(index), }, ]), ), - [ids, slots, canPreview, close, place], + [ids, slots, canPreview, close, openCard], ); const syncScroll = useCallback(() => { @@ -543,19 +549,13 @@ function Rail({ function LaneLine({ color }: { color: string }) { const traveller = useRef(null); - useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - return; - } - - // The traveller spans the whole line with the pulse at its end, so -100% to +pulse crosses any rail length. - const animation = traveller.current?.animate( + // 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 () => animation?.cancel(); - }, []); + ), + ]); return ( (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/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 0f5307fd1..458f73371 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -387,7 +387,7 @@ export default function Show({ const { stop: stopPolling } = usePoll( 30_000, backgroundPollOptions(() => ({ - only: ['newCommitsCount', 'removedCommitIds', 'liveFollowing'], + only: ['newCommitsCount', 'removedCommitIds'], data: { since: newestId, pending: notice?.leaving From 0d24c6882ab913e59ca1ffa13d334474b69114bf Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 21:39:51 +0400 Subject: [PATCH 09/21] fix: feed lines pass behind a live node's LIVE pill The graph canvas cut only each node's circle out of its lines, so the segment below a live author ran over the pill hanging off the ring. Nodes can now mark extra cut-outs with data-graph-cutout. --- resources/js/components/graph-canvas.tsx | 60 ++++++++++++++++++++++++ resources/js/lib/graph.ts | 2 + 2 files changed, 62 insertions(+) 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) => ( Date: Sat, 24 Oct 2026 21:39:51 +0400 Subject: [PATCH 10/21] docs: one live ring rule and the shipped Orbit Rail behaviour --- DESIGN.md | 6 +++--- KNOWLEDGE_BASE.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8637353f8..a797e2fad 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -326,12 +326,12 @@ Under the graph, a mono 13px strip prints the streaks, `longest streak 12 days - **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 right end reads "Live now · 6 you follow" ("Nobody you follow is live" when empty). 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". +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. From sm up the right end reads "Live now · 6 you follow" ("Nobody you follow is live" when empty). 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. 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. +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 c16bae43e..96386e714 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -107,9 +107,9 @@ 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 slowly turning ring in your lane color 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. 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. 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 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 with the stream's thumbnail, their latest commit, when the stream ends in your time zone, and links to watch it and to their profile. The rail refreshes with the log every 30 seconds and drops streams as they end. Other pages list the same people in the side column's **Live now** box, with when each stream ends; logs leave that box out, since the rail already shows them. +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**. On wider screens the row ends with a count, "Live now · 3 you follow", or "Nobody you follow is live". 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. From f5198b8c503a0b23e8e310bb61f2aac490089bb7 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 21:48:07 +0400 Subject: [PATCH 11/21] fix: keep the stream card's actions on one row At 520px the 200px thumbnail left the details column 280px, 6px short of Watch on YouTube and View profile side by side (286px), so View profile wrapped. A 184px thumbnail leaves 296px; the buttons keep their sizes. --- resources/js/components/live/live-preview-card.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/js/components/live/live-preview-card.tsx b/resources/js/components/live/live-preview-card.tsx index 4d1d0c476..aca60eef1 100644 --- a/resources/js/components/live/live-preview-card.tsx +++ b/resources/js/components/live/live-preview-card.tsx @@ -115,7 +115,8 @@ export function LivePreviewCard({ return (
    -
    + {/* At 520px the thumbnail leaves the details 296px, room for Watch on YouTube and View profile on one row. */} +
    Date: Sat, 24 Oct 2026 21:48:07 +0400 Subject: [PATCH 12/21] refactor: drop the rail's follow count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail only ever shows people you follow, so "Live now · N you follow" said nothing the rings don't. The row gives the avatars the full width and keeps its accessible name as an aria-label. --- DESIGN.md | 2 +- KNOWLEDGE_BASE.md | 2 +- .../js/components/orbit-rail/orbit-rail.tsx | 32 ++----------------- resources/js/lib/orbit-rail.ts | 10 ------ tests/Unit/OrbitRail.test.ts | 7 ---- 5 files changed, 5 insertions(+), 48 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a797e2fad..a6dd4c08b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -326,7 +326,7 @@ Under the graph, a mono 13px strip prints the streaks, `longest streak 12 days - **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. From sm up the right end reads "Live now · 6 you follow" ("Nobody you follow is live" when empty). 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". +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. diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 96386e714..5d7014c15 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -109,7 +109,7 @@ Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](re 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**. On wider screens the row ends with a count, "Live now · 3 you follow", or "Nobody you follow is live". 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. +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. diff --git a/resources/js/components/orbit-rail/orbit-rail.tsx b/resources/js/components/orbit-rail/orbit-rail.tsx index feb3c88c5..53063f1c1 100644 --- a/resources/js/components/orbit-rail/orbit-rail.tsx +++ b/resources/js/components/orbit-rail/orbit-rail.tsx @@ -2,14 +2,7 @@ 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, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, -} 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'; @@ -24,12 +17,7 @@ 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, - railSummary, - visibleLive, -} 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'; @@ -103,7 +91,6 @@ function Rail({ [peopleKey], ); - const titleId = useId(); const section = useRef(null); const scroller = useRef(null); const [slots] = useState(() => new Map()); @@ -372,7 +359,7 @@ function Rail({ return (
    @@ -418,19 +405,6 @@ function Rail({
    -

    - {people.length > 0 && ( - - - - - )} - {railSummary(people.length)} -

    - !open && close()} diff --git a/resources/js/lib/orbit-rail.ts b/resources/js/lib/orbit-rail.ts index 50ab1c3d6..bb29ef0ea 100644 --- a/resources/js/lib/orbit-rail.ts +++ b/resources/js/lib/orbit-rail.ts @@ -43,16 +43,6 @@ export function nextIndex( } } -/** - * The rail's right-hand summary for how many people you follow are live, like "Live now · 6 you follow", and - * "Live now · 1 you follow" for one. With nobody live it reads "Nobody you follow is live". - */ -export function railSummary(count: number): string { - return count === 0 - ? 'Nobody you follow is live' - : `Live now · ${count} you follow`; -} - export type CardPlacement = { /** The card's width: 520px, or the column less its gutters when that is narrower. */ width: number; diff --git a/tests/Unit/OrbitRail.test.ts b/tests/Unit/OrbitRail.test.ts index 4a73e1c08..f530d7a98 100644 --- a/tests/Unit/OrbitRail.test.ts +++ b/tests/Unit/OrbitRail.test.ts @@ -3,7 +3,6 @@ import { test } from 'node:test'; import { cardPlacement, nextIndex, - railSummary, visibleLive, } from '../../resources/js/lib/orbit-rail.ts'; @@ -45,12 +44,6 @@ void test('leaves other keys and an empty rail alone', () => { assert.equal(nextIndex(0, 'ArrowRight', 0), null); }); -void test('says how many people you follow are live', () => { - assert.equal(railSummary(0), 'Nobody you follow is live'); - assert.equal(railSummary(1), 'Live now · 1 you follow'); - assert.equal(railSummary(6), 'Live now · 6 you follow'); -}); - void test('opens the card just left of the ring it belongs to', () => { const placement = cardPlacement({ slot: { left: 380, width: 56 }, From 2868d016bd1fb11931834d00395bace2ec7bfe35 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sat, 24 Oct 2026 21:48:07 +0400 Subject: [PATCH 13/21] fix: rail handles clear the LIVE pill when a ring lifts --- resources/js/components/orbit-rail/orbit-rail-item.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/js/components/orbit-rail/orbit-rail-item.tsx b/resources/js/components/orbit-rail/orbit-rail-item.tsx index a308d1c25..7b580c45c 100644 --- a/resources/js/components/orbit-rail/orbit-rail-item.tsx +++ b/resources/js/components/orbit-rail/orbit-rail-item.tsx @@ -97,9 +97,10 @@ export function OrbitRailSlot({ > {children} + {/* Clear of the LIVE pill hanging off the ring, even while the ring is lifted. */} Date: Sat, 24 Oct 2026 21:56:01 +0400 Subject: [PATCH 14/21] fix: the rail ends where its last ring does With the count gone the scroller already spans the row, so drop the one-child flex wrapper, apply the fade mask only while something overflows, and run the lane line from the first ring's centre to the last instead of past both ends. --- .../js/components/orbit-rail/orbit-rail.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/resources/js/components/orbit-rail/orbit-rail.tsx b/resources/js/components/orbit-rail/orbit-rail.tsx index 53063f1c1..8b7c4889a 100644 --- a/resources/js/components/orbit-rail/orbit-rail.tsx +++ b/resources/js/components/orbit-rail/orbit-rail.tsx @@ -360,17 +360,21 @@ function Rail({
    {people.length > 0 && ( @@ -518,7 +522,7 @@ function Rail({ } /** - * The rail's lane through the ring centres, with a pulse of the open card's lane travelling along it. + * 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); @@ -534,7 +538,7 @@ function LaneLine({ color }: { color: string }) { return ( Date: Sun, 25 Oct 2026 00:28:56 +0400 Subject: [PATCH 15/21] perf: pause live rings while they are off screen --- resources/js/components/live/live-avatar.tsx | 27 +++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/resources/js/components/live/live-avatar.tsx b/resources/js/components/live/live-avatar.tsx index fc00e402f..c3566b5ab 100644 --- a/resources/js/components/live/live-avatar.tsx +++ b/resources/js/components/live/live-avatar.tsx @@ -73,6 +73,27 @@ export function LiveRing({ ); } +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' }, @@ -121,7 +142,11 @@ export function LiveAvatar({ surface?: 'card' | 'popover'; }) { return ( - + Date: Sun, 25 Oct 2026 00:29:42 +0400 Subject: [PATCH 16/21] perf: share one clock formatter across the rail's rings --- resources/js/hooks/use-live-until.ts | 3 +-- resources/js/lib/live.ts | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/resources/js/hooks/use-live-until.ts b/resources/js/hooks/use-live-until.ts index dd95ecf75..02380687e 100644 --- a/resources/js/hooks/use-live-until.ts +++ b/resources/js/hooks/use-live-until.ts @@ -1,7 +1,6 @@ import { useSyncExternalStore } from 'react'; import { useNow } from '@/hooks/use-now'; import { formatEndsIn, formatLiveUntil, isStreaming } from '@/lib/live'; -import { browserTimeZone } from '@/lib/utils'; const subscribe = () => () => {}; @@ -25,7 +24,7 @@ export function useLiveUntil( } return { - until: formatLiveUntil(endsAt, browserTimeZone()), + until: formatLiveUntil(endsAt), endsIn: formatEndsIn(endsAt, now), isOver: !isStreaming({ ends_at: endsAt }, now), }; diff --git a/resources/js/lib/live.ts b/resources/js/lib/live.ts index d0f901a14..8ad55696a 100644 --- a/resources/js/lib/live.ts +++ b/resources/js/lib/live.ts @@ -25,16 +25,26 @@ export function isStreaming( 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, like "18:00" or "6:00 PM" in the browser's locale, the same format - * as `formatZonedTime`. + * 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 { - return new Date(endsAt).toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - timeZone, - }); +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)); } /** From 02049357e69649e52fbf503db004db0cb029733a Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sun, 25 Oct 2026 00:32:30 +0400 Subject: [PATCH 17/21] perf: build embed providers only until one plays the link --- app/Services/Embeds.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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; } } From f2e95752a10b88b60909f3317881901b7952e23b Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sun, 25 Oct 2026 00:41:01 +0400 Subject: [PATCH 18/21] perf: send the rail only the fields it draws --- app/Queries/LiveFollowingQuery.php | 11 ++++++++--- resources/js/types/live.ts | 10 +++++++--- tests/Feature/LiveFollowingTest.php | 5 +++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/Queries/LiveFollowingQuery.php b/app/Queries/LiveFollowingQuery.php index 0a986c62d..5a54925fc 100644 --- a/app/Queries/LiveFollowingQuery.php +++ b/app/Queries/LiveFollowingQuery.php @@ -14,6 +14,7 @@ /** * 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> */ @@ -40,8 +41,7 @@ public function get(User $user, int $limit = 24): Collection ->oldest('users.live_ends_at') ->orderBy('users.id') ->limit($limit) - ->get() - ->makeHidden('latest_commit_id'); + ->get(); $ids = $people->pluck('latest_commit_id')->filter()->values()->all(); @@ -56,7 +56,12 @@ public function get(User $user, int $limit = 24): Collection $commit = is_string($id) ? $commits->get($id) : null; return [ - ...$person->toArray(), + '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, '…'), diff --git a/resources/js/types/live.ts b/resources/js/types/live.ts index c7675522c..72d18584f 100644 --- a/resources/js/types/live.ts +++ b/resources/js/types/live.ts @@ -31,10 +31,14 @@ export type LiveCommit = { }; /** - * One entry of the shared `liveFollowing` prop from `LiveFollowingQuery::get()`: the full user array, always with - * `live` set, plus `latest_commit` (null when they have none). Sorted soonest-ending first, at most 24. + * 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 = User & { +export type LiveFollowingUser = Pick< + User, + 'id' | 'username' | 'name' | 'avatar_url' | 'lane' +> & { live: LiveStream; latest_commit: LiveCommit | null; }; diff --git a/tests/Feature/LiveFollowingTest.php b/tests/Feature/LiveFollowingTest.php index b880e9d5c..1986785cc 100644 --- a/tests/Feature/LiveFollowingTest.php +++ b/tests/Feature/LiveFollowingTest.php @@ -43,7 +43,7 @@ function reloadLog(User $viewer, string $props = 'liveFollowing'): TestResponse ->assertJsonPath('props.liveFollowing.*.id', [$later->id]); }); -it('sends each live person as a user with their stream and latest commit', function (): void { +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()]); @@ -73,7 +73,8 @@ function reloadLog(User $viewer, string $props = 'liveFollowing'): TestResponse ]) ->assertJsonMissingPath('props.liveFollowing.0.latest_commit_id') ->assertJsonMissingPath('props.liveFollowing.0.pivot') - ->assertJsonMissingPath('props.liveFollowing.0.live_url'); + ->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 { From 64ae8bb326bd45286e38a821b48e31eb66e37dcd Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sun, 25 Oct 2026 00:41:36 +0400 Subject: [PATCH 19/21] perf: fetch twitch thumbnails at 640x360 --- app/Services/LivePlatforms/Twitch.php | 5 +++-- tests/Feature/Controllers/UserLiveControllerTest.php | 2 +- tests/Feature/LiveFollowingTest.php | 2 +- tests/Unit/Services/LivePlatforms/TwitchTest.php | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/Services/LivePlatforms/Twitch.php b/app/Services/LivePlatforms/Twitch.php index 9fe78c0c4..7fdb04c81 100644 --- a/app/Services/LivePlatforms/Twitch.php +++ b/app/Services/LivePlatforms/Twitch.php @@ -29,7 +29,8 @@ 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, CarbonInterface $endsAt): LiveStream { @@ -37,7 +38,7 @@ public function stream(string $url, CarbonInterface $endsAt): LiveStream $link = "https://www.twitch.tv/{$channel}"; - return new LiveStream(LivePlatform::Twitch, $link, "https://static-cdn.jtvnw.net/previews-ttv/live_user_{$channel}-1280x720.jpg", $endsAt, $this->players->for($link)); + 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/tests/Feature/Controllers/UserLiveControllerTest.php b/tests/Feature/Controllers/UserLiveControllerTest.php index 0df8984de..e98554664 100644 --- a/tests/Feature/Controllers/UserLiveControllerTest.php +++ b/tests/Feature/Controllers/UserLiveControllerTest.php @@ -61,7 +61,7 @@ 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' => [ diff --git a/tests/Feature/LiveFollowingTest.php b/tests/Feature/LiveFollowingTest.php index 1986785cc..679080f41 100644 --- a/tests/Feature/LiveFollowingTest.php +++ b/tests/Feature/LiveFollowingTest.php @@ -54,7 +54,7 @@ function reloadLog(User $viewer, string $props = 'liveFollowing'): TestResponse ->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-1280x720.jpg', + 'thumbnail_url' => 'https://static-cdn.jtvnw.net/previews-ttv/live_user_octocat-640x360.jpg', 'platform' => 'twitch', 'ends_at' => now()->addHours(2)->toIso8601ZuluString(), 'embed' => [ diff --git a/tests/Unit/Services/LivePlatforms/TwitchTest.php b/tests/Unit/Services/LivePlatforms/TwitchTest.php index 3e01f55a1..c1a251763 100644 --- a/tests/Unit/Services/LivePlatforms/TwitchTest.php +++ b/tests/Unit/Services/LivePlatforms/TwitchTest.php @@ -10,7 +10,7 @@ expect($twitch->accepts($url))->toBeTrue() ->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([ From ed25c593676411214d98fd2c8332fb11ce6e7754 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sun, 25 Oct 2026 00:42:10 +0400 Subject: [PATCH 20/21] refactor: drop the live ring's unused options --- resources/css/app.css | 4 ++-- resources/js/components/live/live-avatar.tsx | 25 +++++--------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index 8caf8b965..79f73727f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -15,8 +15,8 @@ background: var(--live-ring); mask: radial-gradient( farthest-side, - transparent calc(100% - var(--live-ring-width, 2px) - 0.5px), - black calc(100% - var(--live-ring-width, 2px)) + transparent calc(100% - 2.5px), + black calc(100% - 2px) ); } diff --git a/resources/js/components/live/live-avatar.tsx b/resources/js/components/live/live-avatar.tsx index c3566b5ab..298f8da34 100644 --- a/resources/js/components/live/live-avatar.tsx +++ b/resources/js/components/live/live-avatar.tsx @@ -27,46 +27,33 @@ function ringGradient(lane: Lane): string { /** * The live look for anything round: a rotating conic ring in the lane's color with a gap before `children`. It - * holds still when `spinning` is off and 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. + * 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. */ -export function LiveRing({ +function LiveRing({ lane, - spinning = true, isGlowing = false, - className, children, }: { lane: Lane; - spinning?: boolean; isGlowing?: boolean; - className?: string; children: ReactNode; }) { - const motion = spinning && 'motion-safe:animate-live-ring'; - return ( {isGlowing && ( )} {children} From 1b2fbff42ba09dbf898fc7dcbef194ab52af6516 Mon Sep 17 00:00:00 2001 From: Arthur Minasyan Date: Sun, 25 Oct 2026 00:42:34 +0400 Subject: [PATCH 21/21] fix: show live end times in the viewer's chosen timezone --- resources/js/hooks/use-live-until.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/resources/js/hooks/use-live-until.ts b/resources/js/hooks/use-live-until.ts index 02380687e..2a367a036 100644 --- a/resources/js/hooks/use-live-until.ts +++ b/resources/js/hooks/use-live-until.ts @@ -1,3 +1,4 @@ +import { usePage } from '@inertiajs/react'; import { useSyncExternalStore } from 'react'; import { useNow } from '@/hooks/use-now'; import { formatEndsIn, formatLiveUntil, isStreaming } from '@/lib/live'; @@ -5,9 +6,9 @@ import { formatEndsIn, formatLiveUntil, isStreaming } from '@/lib/live'; const subscribe = () => () => {}; /** - * When a stream ends, for the viewer's timezone and ticking with `useNow`: `until` like "18:00", `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`. + * 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, @@ -18,13 +19,14 @@ export function useLiveUntil( () => false, ); const now = useNow(); + const timeZone = usePage().props.auth.user?.timezone ?? undefined; if (!isClient) { return null; } return { - until: formatLiveUntil(endsAt), + until: formatLiveUntil(endsAt, timeZone), endsIn: formatEndsIn(endsAt, now), isOver: !isStreaming({ ends_at: endsAt }, now), };