From 0d9b09fd31a344ebdc873a5687024d475250d9ff Mon Sep 17 00:00:00 2001 From: twosox-x Date: Fri, 16 Oct 2026 18:08:31 +0500 Subject: [PATCH] feat: preview images in place, star and save from the keyboard, and show media in quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Images open in an in-app viewer instead of a new tab: attached images in the composer (with Remove) and posted images in the log. Arrows, ←/→, swipe, Esc. - `s` stars and `b` saves the focused commit, listed in the `?` dialog. - Clicking Log while on the log scrolls to the top and loads new commits. - Quote embeds show the quoted commit's first image as a side thumbnail under a gradient, with a +N count for the rest. Co-Authored-By: Claude Opus 5.5 --- app/Http/Controllers/ModerationController.php | 4 +- app/Queries/CommitBuilder.php | 5 +- resources/js/app.tsx | 2 + resources/js/components/app-nav.tsx | 11 + resources/js/components/bookmark-button.tsx | 11 +- resources/js/components/commit-card.tsx | 7 +- .../js/components/commit-image-picker.tsx | 79 ++++-- resources/js/components/commit-images.tsx | 37 ++- .../js/components/commit-star-button.tsx | 5 +- resources/js/components/commit-thread.tsx | 5 +- resources/js/components/image-viewer.tsx | 260 ++++++++++++++++++ .../components/keyboard-shortcuts-dialog.tsx | 2 + resources/js/components/quoted-commit.tsx | 57 +++- resources/js/lib/scroll-to-top.ts | 14 + resources/js/pages/log/show.tsx | 10 + resources/js/types/commit.ts | 1 + .../Feature/Controllers/LogControllerTest.php | 21 ++ tests/Unit/CommitImagePicker.test.tsx | 35 +++ 18 files changed, 525 insertions(+), 41 deletions(-) create mode 100644 resources/js/components/image-viewer.tsx create mode 100644 resources/js/lib/scroll-to-top.ts create mode 100644 tests/Unit/CommitImagePicker.test.tsx diff --git a/app/Http/Controllers/ModerationController.php b/app/Http/Controllers/ModerationController.php index d65dd4404..190448208 100644 --- a/app/Http/Controllers/ModerationController.php +++ b/app/Http/Controllers/ModerationController.php @@ -31,10 +31,10 @@ public function show(ShowModerationRequest $request, #[CurrentUser] User $user): 'parent.user', 'linkPreview', 'images', - 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with('user'), + 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['user', 'images']), 'seriesParts' => fn (HasMany $query): HasMany => $query ->where('status', CommitStatus::Pending) - ->with(['user', 'linkPreview', 'images', 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with('user')]) + ->with(['user', 'linkPreview', 'images', 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['user', 'images'])]) ->oldest('series_position'), ]) ->withStarsFor($user) diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index f998f3581..1d42036bd 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -99,7 +99,8 @@ public function withVisibleReplyCount(?User $user): self } /** - * Loads the quoted commit only when the given user may see it, and as a tombstone once deleted. + * Loads the quoted commit only when the given user may see it, and as a tombstone once deleted. Its images come + * along for the embed's thumbnail; a tombstone never serializes them. */ public function withQuotedCommitFor(?User $viewer): self { @@ -107,7 +108,7 @@ public function withQuotedCommitFor(?User $viewer): self 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query ->withTrashed() ->where(fn (self $query): self => $query->visibleTo($viewer)) - ->with('user'), + ->with(['user', 'images']), ]); } diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 0ed0943c9..4fb9ffe80 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -3,6 +3,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import type { ComponentType } from 'react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { ImageViewerHost } from '@/components/image-viewer'; import { Toaster } from '@/components/ui/sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; import { initializeTheme } from '@/hooks/use-appearance'; @@ -25,6 +26,7 @@ void createInertiaApp({ + , ); diff --git a/resources/js/components/app-nav.tsx b/resources/js/components/app-nav.tsx index 1322a58d0..fe0133f32 100644 --- a/resources/js/components/app-nav.tsx +++ b/resources/js/components/app-nav.tsx @@ -27,6 +27,7 @@ import { import { UserAvatar } from '@/components/user-avatar'; import { UserMenuContent } from '@/components/user-menu-content'; import { useCurrentUrl } from '@/hooks/use-current-url'; +import { requestScrollToTop } from '@/lib/scroll-to-top'; import { cn, toUrl } from '@/lib/utils'; import { home, login } from '@/routes'; import { show } from '@/routes/user'; @@ -134,6 +135,16 @@ function NavLink({ prefetch={item.prefetch ?? true} viewTransition aria-current={item.isActive ? 'page' : undefined} + onClick={(event) => { + if ( + item.isActive && + item.title === 'Log' && + !(event.metaKey || event.ctrlKey || event.shiftKey) + ) { + event.preventDefault(); + requestScrollToTop(); + } + }} className={className} > {children} diff --git a/resources/js/components/bookmark-button.tsx b/resources/js/components/bookmark-button.tsx index be545c981..f3650342c 100644 --- a/resources/js/components/bookmark-button.tsx +++ b/resources/js/components/bookmark-button.tsx @@ -11,7 +11,13 @@ export const BookmarkRemovalContext = createContext< ((id: string) => void) | null >(null); -export function BookmarkButton({ commit }: { commit: Commit }) { +export function BookmarkButton({ + commit, + keyShortcut, +}: { + commit: Commit; + keyShortcut?: string; +}) { const { auth } = usePage().props; const [isSaved, setIsSaved] = useState(commit.is_saved ?? false); const request = useHttp, { is_saved: boolean }>({}); @@ -57,7 +63,8 @@ export function BookmarkButton({ commit }: { commit: Commit }) { aria-disabled={request.processing} aria-label={isSaved ? 'Unsave commit' : 'Save commit'} aria-pressed={isSaved} - title={isSaved ? 'Unsave commit' : 'Save commit privately'} + aria-keyshortcuts={keyShortcut} + title={`${isSaved ? 'Unsave commit' : 'Save commit privately'}${keyShortcut ? ` (${keyShortcut})` : ''}`} className={cn( 'rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50', { 'text-primary hover:text-primary': isSaved }, diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 1fb4934e5..10a1fc962 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -328,6 +328,7 @@ export function CommitCard({ - + )} diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 2fb54c659..93e9bf9ef 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -1,5 +1,6 @@ import { ImagePlus, X } from 'lucide-react'; -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ImageViewer } from '@/components/image-viewer'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -99,6 +100,7 @@ export function ImagePickerPreviews({ images: File[]; onRemove: (index: number) => void; }) { + const [viewing, setViewing] = useState(null); const urls = useMemo( () => images.map((image) => URL.createObjectURL(image)), [images], @@ -114,24 +116,61 @@ export function ImagePickerPreviews({ } return ( -
    - {urls.map((url, index) => ( -
  • - {`Attached - -
  • - ))} -
+ <> +
    + {urls.map((url, index) => ( +
  • + + +
  • + ))} +
+ + ({ + url: urls[index], + alt: `Attached image ${index + 1}`, + details: `${image.name} · ${formatBytes(image.size)}`, + }))} + index={viewing} + onIndexChange={setViewing} + onRemove={(index) => { + onRemove(index); + setViewing( + images.length <= 1 + ? null + : Math.min(index, images.length - 2), + ); + }} + /> + ); } + +function formatBytes(bytes: number): string { + if (bytes < 1024 * 1024) { + return `${Math.max(1, Math.round(bytes / 1024))} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/resources/js/components/commit-images.tsx b/resources/js/components/commit-images.tsx index 67f8e9cc3..672b6992c 100644 --- a/resources/js/components/commit-images.tsx +++ b/resources/js/components/commit-images.tsx @@ -1,3 +1,4 @@ +import { viewImages } from '@/components/image-viewer'; import { cn } from '@/lib/utils'; import type { CommitImage } from '@/types'; @@ -22,18 +23,34 @@ export function CommitImages({ images }: { images: CommitImage[] }) { href={image.url} target="_blank" rel="noopener noreferrer" - onClick={(event) => event.stopPropagation()} - className={cn('block min-h-0 bg-muted', { + onClick={(event) => { + event.stopPropagation(); + + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.button !== 0 + ) { + return; + } + + event.preventDefault(); + viewImages( + images.map((image, position) => ({ + url: image.url, + alt: altFor(position, images.length), + })), + index, + ); + }} + className={cn('block min-h-0 cursor-zoom-in bg-muted', { 'row-span-2': images.length === 3 && index === 0, })} > { ); } + +function altFor(index: number, count: number): string { + return count === 1 + ? 'Attached image' + : `Attached image ${index + 1} of ${count}`; +} diff --git a/resources/js/components/commit-star-button.tsx b/resources/js/components/commit-star-button.tsx index af0bacd2d..f15e22b82 100644 --- a/resources/js/components/commit-star-button.tsx +++ b/resources/js/components/commit-star-button.tsx @@ -20,9 +20,11 @@ function starLabel(starred: boolean, count: number): string { export function CommitStarButton({ commit, + keyShortcut, className, }: { commit: Commit; + keyShortcut?: string; className?: string; }) { const { auth } = usePage().props; @@ -95,7 +97,8 @@ export function CommitStarButton({ onClick={toggle} aria-pressed={starred} aria-label={starLabel(starred, count)} - title={starred ? 'Unstar' : 'Star'} + aria-keyshortcuts={keyShortcut} + title={`${starred ? 'Unstar' : 'Star'}${keyShortcut ? ` (${keyShortcut})` : ''}`} className={cn( 'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', starred && 'text-primary hover:text-primary', diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index f78c0d8b9..ca419b1af 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -309,7 +309,10 @@ export function ThreadActions({
{!isDeleted(commit) && commit.is_saved !== undefined && ( - + )} {!isDeleted(commit) && ( (openEvent, { detail: { images, index } }), + ); +} + +/** + * The one viewer that posted images open in, mounted once for the whole app. + */ +export function ImageViewerHost() { + const [request, setRequest] = useState(null); + + useEffect(() => { + const open = (event: Event) => + setRequest((event as CustomEvent).detail); + + window.addEventListener(openEvent, open); + + return () => window.removeEventListener(openEvent, open); + }, []); + + return ( + + setRequest((current) => + index === null || current === null + ? null + : { ...current, index }, + ) + } + /> + ); +} + +/** + * A focused look at images: dims the page instead of reflowing it, fills the screen on phones, and steps through + * images with the arrows, ←/→, or a swipe. Esc, the close button, or a click beside the image returns to the page. + */ +export function ImageViewer({ + images, + index, + onIndexChange, + onRemove, +}: { + images: ViewerImage[]; + index: number | null; + onIndexChange: (index: number | null) => void; + onRemove?: (index: number) => void; +}) { + const swipeStart = useRef(null); + const [dimensions, setDimensions] = useState(null); + const isOpen = index !== null && index < images.length; + const current = isOpen ? index : 0; + const image = images[current] as ViewerImage | undefined; + const hasMany = images.length > 1; + + const step = (by: 1 | -1) => { + if (!isOpen || !hasMany) { + return; + } + + onIndexChange((current + by + images.length) % images.length); + }; + + return ( + { + if (!open) { + onIndexChange(null); + } + }} + > + + + { + if (event.key === 'ArrowRight') { + event.preventDefault(); + step(1); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + step(-1); + } + }} + onPointerDown={(event) => { + swipeStart.current = event.clientX; + }} + onPointerUp={(event) => { + if (swipeStart.current === null) { + return; + } + + const distance = event.clientX - swipeStart.current; + swipeStart.current = null; + + if ( + event.pointerType !== 'mouse' && + Math.abs(distance) > swipeThreshold + ) { + step(distance < 0 ? 1 : -1); + } + }} + className="fixed inset-0 z-50 flex flex-col text-white outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 motion-reduce:animate-none sm:inset-6 md:inset-10" + data-test="image-viewer" + > + + Image {current + 1} of {images.length} + + + Use the arrow keys to switch images, and Escape to + close. + + +
+ + {current + 1}/{images.length} + + + {[image?.details, dimensions] + .filter(Boolean) + .join(' · ')} + + + + +
+ +
{ + if (event.target === event.currentTarget) { + onIndexChange(null); + } + }} + > + {image && ( + {image.alt} + setDimensions( + `${event.currentTarget.naturalWidth}×${event.currentTarget.naturalHeight}`, + ) + } + draggable={false} + className="max-h-full max-w-full rounded-md object-contain shadow-2xl select-none motion-safe:animate-in motion-safe:duration-200 motion-safe:fade-in-0 motion-safe:zoom-in-[0.97]" + data-test="image-viewer-image" + /> + )} + + {hasMany && ( + <> + step(-1)} + className="left-2 sm:left-3" + > + + + step(1)} + className="right-2 sm:right-3" + > + + + + )} +
+ +
+ {hasMany && ( +
+ {images.map((dot, position) => ( + + ))} +
+ )} + {onRemove && ( + + )} +
+
+
+
+ ); +} + +function ViewerArrow({ + label, + onClick, + className, + children, +}: { + label: string; + onClick: () => void; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/resources/js/components/keyboard-shortcuts-dialog.tsx b/resources/js/components/keyboard-shortcuts-dialog.tsx index 1e3c72488..c531955bb 100644 --- a/resources/js/components/keyboard-shortcuts-dialog.tsx +++ b/resources/js/components/keyboard-shortcuts-dialog.tsx @@ -14,6 +14,8 @@ const shortcuts: Shortcut[] = [ { keys: ['↵'], label: 'Open the focused commit' }, { keys: ['r'], label: 'Reply to commit' }, { keys: ['q'], label: 'Quote commit' }, + { keys: ['s'], label: 'Star or unstar commit' }, + { keys: ['b'], label: 'Save or unsave commit' }, { keys: ['o'], label: 'Show or hide replies' }, { keys: ['c'], label: 'Write a commit' }, { keys: ['⌘', 'K'], label: 'Search developers' }, diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index 5bd8bd65c..196232f43 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -4,7 +4,11 @@ import { CommitBodyExcerpt } from '@/components/commit-body'; import { UserAvatar } from '@/components/user-avatar'; import { useNow } from '@/hooks/use-now'; import { cn, formatRelativeTime, isDeleted } from '@/lib/utils'; -import type { DeletedCommit, QuotedCommit as QuotedCommitData } from '@/types'; +import type { + CommitImage, + DeletedCommit, + QuotedCommit as QuotedCommitData, +} from '@/types'; export function QuotedCommit({ commit, @@ -30,6 +34,7 @@ export function QuotedCommit({ ); } + const images = commit.images ?? []; const className = 'mt-2 block rounded-lg border px-3 py-2'; const content = ( @@ -66,10 +71,13 @@ export function QuotedCommit({ )} - +
+ + {images.length > 0 && } +
); @@ -94,3 +102,42 @@ export function QuotedCommit({ ); } + +/** + * A teaser of the quoted commit's images, not the gallery: one thumbnail on the side, dimmed by a gradient and + * counting the rest, so the full picture stays one click away on the original commit. + */ +function QuotedMedia({ images }: { images: CommitImage[] }) { + const [first] = images; + + return ( + + + + {images.length > 1 && ( + + +{images.length - 1} + + )} + + {images.length === 1 + ? 'With 1 image' + : `With ${images.length} images`} + + + ); +} diff --git a/resources/js/lib/scroll-to-top.ts b/resources/js/lib/scroll-to-top.ts new file mode 100644 index 000000000..cb78a6ed6 --- /dev/null +++ b/resources/js/lib/scroll-to-top.ts @@ -0,0 +1,14 @@ +const event = 'devnull:scroll-to-top'; + +/** + * Clicking the nav item for the page you are already on asks that page to go back to the top. + */ +export function requestScrollToTop(): void { + window.dispatchEvent(new Event(event)); +} + +export function onScrollToTopRequest(callback: () => void): () => void { + window.addEventListener(event, callback); + + return () => window.removeEventListener(event, callback); +} diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 2402a6992..7a398dbb7 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -15,6 +15,7 @@ import { useLastVisit } from '@/hooks/use-last-visit'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; import { emojify } from '@/lib/emoji'; +import { onScrollToTopRequest } from '@/lib/scroll-to-top'; import { readStorage, writeStorage } from '@/lib/storage'; import type { Commit, CursorPaginated, Log } from '@/types'; @@ -109,6 +110,15 @@ export default function Show({ onSuccess: () => window.scrollTo({ top: 0, behavior: 'smooth' }), }); + // Clicking Log while already on it goes back to the top, picking up new commits on the way, like X. + useEffect(() => + onScrollToTopRequest(() => + newCommitsCount > 0 + ? showNewCommits() + : window.scrollTo({ top: 0, behavior: 'smooth' }), + ), + ); + return ( <> diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 238e7ed90..521c4ffad 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -64,6 +64,7 @@ export type QuotedCommit = Pick< | 'deleted_at' | 'user' | 'replies_count' + | 'images' >; export type DeletedCommit = { diff --git a/tests/Feature/Controllers/LogControllerTest.php b/tests/Feature/Controllers/LogControllerTest.php index d3294af75..f200b2254 100644 --- a/tests/Feature/Controllers/LogControllerTest.php +++ b/tests/Feature/Controllers/LogControllerTest.php @@ -7,6 +7,7 @@ use App\Enums\Log; use App\Http\Middleware\HandleInertiaRequests; use App\Models\Commit; +use App\Models\CommitImage; use App\Models\LinkPreview; use App\Models\User; use App\Queries\LatestLogCommitsQuery; @@ -410,6 +411,26 @@ ->assertDontSee('A deleted quoted commit'); })->with(Log::cases()); +it('includes the images of quoted commits, but never of deleted ones', function (Log $log): void { + $user = User::factory()->create(); + $quoted = Commit::factory()->create(['created_at' => now()->subHour()]); + $image = CommitImage::factory()->for($quoted)->create(); + $deleted = Commit::factory()->create(['created_at' => now()->subHour()]); + CommitImage::factory()->for($deleted)->create(); + Commit::factory()->for($user)->for($quoted, 'quotedCommit')->create(['created_at' => now()->subMinutes(2)]); + Commit::factory()->for($user)->for($deleted, 'quotedCommit')->create(['created_at' => now()->subMinute()]); + $deleted->delete(); + + $response = $this->actingAs($user) + ->get(route('log.show', $log)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->missing('commits.data.0.quoted_commit.images') + ->where('commits.data.1.quoted_commit.images.0.id', $image->id) + ->where('commits.data.1.quoted_commit.images.0.url', route('commit-image.show', $image))); +})->with(Log::cases()); + it('loads the quoted commits of a log without a query per commit', function (Log $log): void { $user = User::factory()->create(['log' => $log]); diff --git a/tests/Unit/CommitImagePicker.test.tsx b/tests/Unit/CommitImagePicker.test.tsx new file mode 100644 index 000000000..738caf304 --- /dev/null +++ b/tests/Unit/CommitImagePicker.test.tsx @@ -0,0 +1,35 @@ +/// + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { renderToString } from 'react-dom/server'; +import { ImagePickerPreviews } from '../../resources/js/components/commit-image-picker'; + +const image = (name: string) => + new File([new Uint8Array(8)], name, { type: 'image/png' }); + +void test('renders nothing without attached images', () => { + assert.equal( + renderToString( + undefined} />, + ), + '', + ); +}); + +void test('makes every attached image open the preview, with its own remove button', () => { + const html = renderToString( + undefined} + />, + ); + + assert.equal( + html.match(/data-test="attached-image-preview-button"/g)?.length, + 2, + ); + assert.match(html, /aria-label="Preview image 1"/); + assert.match(html, /aria-label="Remove image 2"/); + assert.doesNotMatch(html, /data-test="image-viewer"/); +});