diff --git a/DESIGN.md b/DESIGN.md index abe4bab8b..1593aad4c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -271,7 +271,7 @@ Quiet, rectangular, decisive. ### Inputs / Fields - **Composer:** borderless, no visible field chrome: a 17px textarea ("What did you ship?", max 4,000) that grows with content. Its state is carried by the graph: the staged node is hollow until the body has a character, then fills with the author's lane; the tail below it is a dashed Rail Gray line. A mono countdown sits left of the "Commit" button; ⌘/Ctrl+Enter commits. -- **Preview:** an eye icon button after the image picker (Blue Wash with Commit Blue icon while pressed) swaps the textarea for the draft rendered as the posted commit at 15px, in the same spot and never shorter than the textarea, so the counter row stays put. Attached images show as the posted grid, and the URL that will unfurl sits in a dashed hairline box with a mono URL and "Unfurls into a link card after you commit." ⌘/Ctrl+Shift+P toggles it; Esc returns to writing; ⌘/Ctrl+Enter still commits. +- **Preview:** an eye icon button after the image picker (Blue Wash with Commit Blue icon while pressed) swaps the textarea for the draft rendered as the posted commit at 15px, in the same spot and never shorter than the textarea, so the counter row stays put. Attached images show as the posted grid, and the URL that will unfurl sits in a dashed hairline box with a mono URL and "Unfurls into a link card after you commit." (a /dev/null commit shows the /dev/null mark, mono `handle/hash`, and "Unfurls into a commit card after you commit."). ⌘/Ctrl+Shift+P toggles it; Esc returns to writing; ⌘/Ctrl+Enter still commits. - **Stash:** unsent text in the composer and in each reply composer is kept in `localStorage` per signed-in user (cleared on commit, when emptied, and on log out). When a draft comes back after a reload or navigation, a quiet mono `stash@{0}` (title "Restored your unsent draft") sits at the left of the counter row with a muted `drop` text button that discards it. Merely typing never shows it. A thread with a stashed reply prints a muted 11px mono `stash@{0}` after its "Reply" action; Cancel and Escape close the reply composer but keep the stash. Neither element is blue. - **Read-only fields (settings):** a hairline-bordered 8px-radius list, mono keys in a 6.5rem column, values in sans; missing values print a mono `null`. - **Color picker (settings):** a live preview row (rail node, 48px avatar with the lane ring, lane-dotted handle) above a radio group of seven 28px swatches with sans captions: a dashed "Auto" swatch showing the hashed lane, then the six lanes. The checked swatch gets the 2px lane ring offset from the surface; picking another swatch slides the ring over to it and crossfades the preview into the new lane; focus shows the standard focus ring on the whole option. Saved with a primary "Save color" button, disabled until the choice changes. Only the six lanes are offered so light/dark lifts and contrast keep working. @@ -338,6 +338,8 @@ A deleted commit that still has visible replies keeps its place so the conversat ### Quotes A quote is a regular commit row with the quoted commit embedded under its body: a hairline-bordered, 8px-rounded block (like a link preview, no lane color) with a 20px avatar, name (600), mono handle, and mono relative time on one line, then the body at 15px, pre-wrapped and clamped to six lines. The whole block is one link to the quoted commit's page and tints Accent on hover/focus; it embeds one level only. A deleted quoted commit is a dashed block with a mono `[deleted]` token and "This commit was deleted."; one the viewer cannot see reads `[unavailable]` and "This commit isn't available.", with no author, time, or body. "Quote" sits in the action row after the star (icon only on phones) and opens a dialog titled "Quote @handle": the viewer's avatar, a 17px textarea ("Add your take") with its own stash, the quoted block as a static preview, a counter, Cancel, and Quote (⌘/Ctrl+Enter). It is hidden wherever the star is. +A link card for a /dev/null commit reads as that commit rather than a web page: the /dev/null mark and mono `handle/hash` where other cards put the site name, then the body at 15px in Foreground, clamped to three lines. Its thumbnail is the commit's own first image, never the drawn link preview of the commit, and the card opens the commit in place instead of a new tab. + ### Commit Page Every commit has a page at `/@handle/commits/{id}`, reached from its relative time or short hash (both links), the "↳ replying to @handle" label, or Enter on a focused commit. A sticky header reads "Commit" with the mono short hash and a copy-link icon on the right. Visible ancestors (up to 20) stack above as regular commit rows on one continuous rail, without delete, with deleted ones as tombstones; the focused commit sits last with a 17px body, a mono "time · date · commit hash" line, and its replies already expanded. A deleted commit's own page is a 404. diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index f0a456624..8103d4c01 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -238,7 +238,7 @@ Sources: [image picker](resources/js/components/commit-image-picker.tsx), [image Each link in a commit or reply counts as at most **23 characters** toward the 4,000-character allowance; shorter links count at their actual length. This also applies to quotes, scheduled commits, previews, and terminal and app submissions. The full URL is stored and followed directly, not through a redirect service. Prose URLs longer than **2,048 characters** are rejected, including the inferred `https://` scheme for bare domains. Code still counts literally toward the 4,000-character allowance. Bodies are additionally capped at 40,000 raw characters to bound storage and processing. Private messages and issue drafts keep their existing literal character limits. -URLs and bare domains in commits and replies become clickable links. Long URLs are shortened visually; hover reveals the full destination. GitHub repository links show as `owner/repo`, and issue or pull request links as `owner/repo#123`, with the GitHub mark. Links to /dev/null commits show as the author's username and a short hash. Code keeps its literal text. The first link normally gets a preview card with available title, description, and image. In the commit, reply, and quote composers, choose another link from the body or choose no card; terminal commits and app commits always use the first link. Missing site metadata may leave a link without a rich preview. +URLs and bare domains in commits and replies become clickable links. Long URLs are shortened visually; hover reveals the full destination. GitHub repository links show as `owner/repo`, and issue or pull request links as `owner/repo#123`, with the GitHub mark. Links to /dev/null commits show as the author's username and a short hash, and their preview card shows the /dev/null mark, the same username and hash, the commit's text, and its own first image if it has one, opening the commit's page in the same tab. Code keeps its literal text. The first link normally gets a preview card with available title, description, and image. In the commit, reply, and quote composers, choose another link from the body or choose no card; terminal commits and app commits always use the first link. Missing site metadata may leave a link without a rich preview. Supported links can also show click-to-play embedded players: YouTube, Vimeo, Loom, Twitch, Spotify, SoundCloud, Apple Music, Suno, CodePen, and asciinema. Only the link that gets the card can show a player, so choosing no card means no player. Embeds depend on server configuration and the linked URL being supported. Third-party player content loads only after you press play; those providers have their own privacy policies. This is embedding a link, not uploading a video or audio file to /dev/null. diff --git a/resources/js/components/composer-preview.tsx b/resources/js/components/composer-preview.tsx index e6bb61986..2ac15058a 100644 --- a/resources/js/components/composer-preview.tsx +++ b/resources/js/components/composer-preview.tsx @@ -4,6 +4,7 @@ import type { KeyboardEvent, RefObject } from 'react'; import { useId, useState } from 'react'; import CommitPreviewController from '@/actions/App/Http/Controllers/CommitPreviewController'; import { ActionTooltip } from '@/components/action-tooltip'; +import AppLogoIcon from '@/components/app-logo-icon'; import { CommitBody } from '@/components/commit-body'; import { objectUrlFor } from '@/components/commit-image-picker'; import { CommitImages } from '@/components/commit-images'; @@ -14,7 +15,8 @@ import { Button } from '@/components/ui/button'; import { useNow } from '@/hooks/use-now'; import { bodySegments, hasCodeBlock } from '@/lib/code'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; -import { cn } from '@/lib/utils'; +import { commitLinkTo } from '@/lib/urls'; +import { cn, shortHash } from '@/lib/utils'; import type { BodySegment } from '@/types'; type Mentions = Record; @@ -181,6 +183,8 @@ export function ComposerPreviewBody({ url: string | null; className?: string; }) { + const commitLink = url ? commitLinkTo(url) : null; + return (
- + {commitLink ? ( + + ) : ( + + )}

- {url.replace(/^https?:\/\//i, '')} + {commitLink + ? `${commitLink.handle}/${shortHash(commitLink.commit)}` + : url.replace(/^https?:\/\//i, '')}

- Unfurls into a link card after you commit. + Unfurls into a {commitLink ? 'commit' : 'link'} card + after you commit.

diff --git a/resources/js/components/link-preview.tsx b/resources/js/components/link-preview.tsx index 513987a24..0cd28080d 100644 --- a/resources/js/components/link-preview.tsx +++ b/resources/js/components/link-preview.tsx @@ -1,11 +1,18 @@ +import { Link } from '@inertiajs/react'; import { useEffect, useState } from 'react'; +import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import AppLogoIcon from '@/components/app-logo-icon'; import { LinkEmbed } from '@/components/link-embed'; import { Skeleton } from '@/components/ui/skeleton'; -import { hostname } from '@/lib/urls'; +import { commitLinkTo, hostname } from '@/lib/urls'; +import { shortHash } from '@/lib/utils'; import type { LinkPreview as LinkPreviewData } from '@/types'; const POLL_DELAYS = [1000, 2000, 3000, 5000, 8000, 13000]; +// The picture a commit's page shares is the commit drawn as a card, which its own link card already shows as text. +const drawnCommitPattern = /\/og\.png(?:\?|$)/; + /** * A link is unfurled in the background after its commit or message is posted, so a pending preview is fetched again * from the refresh URL until the page behind it has been read, instead of only showing up after a reload. @@ -78,26 +85,35 @@ function useFetchedLinkPreview( return { preview: current, isWaiting: current.is_pending && !hasGivenUp }; } -function LinkPreviewSkeleton() { +function LinkPreviewSkeleton({ isCommit }: { isCommit: boolean }) { return (
- + {!isCommit && }
- - - - - - - - + {isCommit ? ( + + + + + ) : ( + <> + + + + + + + + + + )}
); @@ -117,7 +133,12 @@ export function LinkPreview({ initialPreview, ); const [isImageBroken, setIsImageBroken] = useState(false); - const imageUrl = isImageBroken ? null : preview.image_url; + const commitLink = commitLinkTo(preview.url); + const imageUrl = + isImageBroken || + (commitLink && drawnCommitPattern.test(preview.image_url ?? '')) + ? null + : preview.image_url; if (preview.embed) { return ( @@ -130,7 +151,7 @@ export function LinkPreview({ } if (isWaiting) { - return ; + return ; } if (!preview.title) { @@ -151,21 +172,50 @@ export function LinkPreview({ )}
- - {preview.site_name ?? hostname(preview.url)} - - - {preview.title} - - {preview.description && ( - - {preview.description} - + {commitLink ? ( + <> + + + + @{commitLink.handle}’s commit{' '} + {shortHash(commitLink.commit)} + + + {commitLink.handle}/ + {shortHash(commitLink.commit)} + + + {preview.description && ( + + {preview.description} + + )} + + ) : ( + <> + + {preview.site_name ?? hostname(preview.url)} + + + {preview.title} + + {preview.description && ( + + {preview.description} + + )} + )}
); + const linkClassName = + 'mt-3 flex items-center overflow-hidden rounded-lg border transition-colors hover:bg-accent focus-visible:bg-accent'; + if (isStatic) { return (
event.stopPropagation()} + className={linkClassName} + data-test="link-preview" + > + {content} + + ); + } + return ( event.stopPropagation()} - className="mt-3 flex items-center overflow-hidden rounded-lg border transition-colors hover:bg-accent focus-visible:bg-accent" + className={linkClassName} data-test="link-preview" > {content} diff --git a/resources/js/components/linkified-text.tsx b/resources/js/components/linkified-text.tsx index 483164a1a..50dde4256 100644 --- a/resources/js/components/linkified-text.tsx +++ b/resources/js/components/linkified-text.tsx @@ -14,6 +14,7 @@ import type { Color } from '@/lib/colors'; import { colorsIn } from '@/lib/colors'; import { mentionPattern } from '@/lib/mentions'; import { + commitLinkTo, hrefFor, shortenUrl, urlPattern, @@ -23,9 +24,6 @@ import { cn, shortHash } from '@/lib/utils'; import { show } from '@/routes/mention'; import type { BodySegment, Commit } from '@/types'; -// A link to a commit on dev/null itself, nothing after its id, so it can show as the author and the commit's hash. -const commitLinkPattern = - /^https?:\/\/(?:www\.)?dev-null\.sh\/@([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})\/commits\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; const gitHubHostPattern = /^(?:www\.)?github\.com$/i; const gitHubOwnerPattern = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; @@ -756,7 +754,7 @@ export function LinkifiedText({ continue; } - const commitLink = commitLinkPattern.exec(linkText); + const commitLink = commitLinkTo(linkText); const forgeLink = forgeLinkTo(href); parts.push( @@ -772,8 +770,8 @@ export function LinkifiedText({ ) : forgeLink ? ( diff --git a/resources/js/lib/urls.ts b/resources/js/lib/urls.ts index 021adde17..262dda481 100644 --- a/resources/js/lib/urls.ts +++ b/resources/js/lib/urls.ts @@ -97,6 +97,21 @@ export function shortenUrl(url: string): ShortenedUrl { }; } +// A link to a commit on dev/null itself, nothing after its id, so it can show as the author and the commit's hash. +const commitLinkPattern = + /^https?:\/\/(?:www\.)?dev-null\.sh\/@([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})\/commits\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; + +/** + * The author's handle and the commit's id, lowercased, when a link points at a commit on dev/null itself. + */ +export function commitLinkTo( + url: string, +): { handle: string; commit: string } | null { + const match = commitLinkPattern.exec(url); + + return match ? { handle: match[1], commit: match[2].toLowerCase() } : null; +} + export function hostname(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ''); diff --git a/tests/Unit/CommitLinks.test.ts b/tests/Unit/CommitLinks.test.ts new file mode 100644 index 000000000..152f162e9 --- /dev/null +++ b/tests/Unit/CommitLinks.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { commitLinkTo } from '../../resources/js/lib/urls.ts'; + +const id = '01a16ffc-740e-7105-82dc-e3a7ed20aaaa'; + +await test('reads the author and the commit from a link to a dev/null commit', () => { + assert.deepEqual( + commitLinkTo(`https://dev-null.sh/@sulimanbenhalim/commits/${id}`), + { handle: 'sulimanbenhalim', commit: id }, + ); + assert.deepEqual( + commitLinkTo( + `http://www.dev-null.sh/@nuno-maduro/commits/${id.toUpperCase()}`, + ), + { handle: 'nuno-maduro', commit: id }, + ); +}); + +await test('ignores links to anything else', () => { + for (const url of [ + 'https://dev-null.sh/@sulimanbenhalim', + `https://dev-null.sh/@sulimanbenhalim/commits/${id}/og.png`, + `https://dev-null.sh/@sulimanbenhalim/commits/${id}?ref=log`, + `https://dev-null.sh.example.com/@sulimanbenhalim/commits/${id}`, + `https://github.com/@sulimanbenhalim/commits/${id}`, + `https://dev-null.sh/@-sulimanbenhalim/commits/${id}`, + `https://dev-null.sh/@sulimanbenhalim/commits/not-a-commit`, + ]) { + assert.equal(commitLinkTo(url), null, url); + } +}); diff --git a/tests/Unit/LinkPreview.test.tsx b/tests/Unit/LinkPreview.test.tsx new file mode 100644 index 000000000..bf6a98674 --- /dev/null +++ b/tests/Unit/LinkPreview.test.tsx @@ -0,0 +1,105 @@ +/// + +import assert from 'node:assert/strict'; +import { createInertiaApp } from '@inertiajs/react'; +import { renderToString } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import { LinkPreview } from '../../resources/js/components/link-preview'; +import type { LinkPreview as LinkPreviewData } from '../../resources/js/types'; + +const id = '01a16ffc-740e-7105-82dc-e3a7ed20aaaa'; + +const commitCard = { + url: `https://dev-null.sh/@nunomaduro/commits/${id}`, + title: 'Nuno Maduro (@nunomaduro) on /dev/null: “shipping it”', + description: 'shipping it', + image_url: `https://dev-null.sh/@nunomaduro/commits/${id}/og.png?v=1`, + site_name: '/dev/null', + is_pending: false, + embed: null, +} satisfies LinkPreviewData; + +async function render( + preview: LinkPreviewData, + isStatic = false, +): Promise { + const result = await createInertiaApp({ + page: { + component: 'card', + props: { errors: {}, auth: { user: null, pasteis: null } }, + url: '/', + version: null, + clearHistory: false, + encryptHistory: false, + rescuedProps: [], + rememberedState: {}, + flash: {}, + }, + resolve: () => () => ( + + ), + render: renderToString, + setup: ({ App, props }) => , + }); + + assert.ok(result); + + return result.body; +} + +void test('shows a dev/null commit link card as the commit, opened in place', async () => { + const body = await render(commitCard); + + assert.match(body, new RegExp(`]*href="/@nunomaduro/commits/${id}"`)); + assert.doesNotMatch(body, /target="_blank"/); + assert.match(body, /nunomaduro()?\/()?d20aaaa/); + assert.match(body, />shipping it { + const image = `https://dev-null.sh/commit-images/${id}`; + const body = await render({ ...commitCard, image_url: image }); + + assert.match(body, new RegExp(`]*src="${image}"`)); +}); + +void test('shows a dev/null commit link card without a link inside a quote', async () => { + const body = await render(commitCard, true); + + assert.doesNotMatch(body, /shipping it { + const body = await render({ + ...commitCard, + url: 'https://laravel.com', + title: 'Laravel', + description: 'The PHP framework for web artisans.', + image_url: 'https://laravel.com/og.png', + site_name: 'Laravel', + }); + + assert.match(body, /]*href="https:\/\/laravel.com"[^>]*target="_blank"/); + assert.match(body, />Laravel]*src="https:\/\/laravel.com\/og.png"/); +}); + +void test('waits for a dev/null commit link card without a thumbnail', async () => { + const pending = await render({ ...commitCard, is_pending: true }); + const other = await render({ + ...commitCard, + url: 'https://laravel.com', + is_pending: true, + }); + + assert.match(pending, /data-test="link-preview-skeleton"/); + assert.doesNotMatch(pending, /size-20/); + assert.match(other, /size-20/); +});