Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 21 additions & 7 deletions resources/js/components/composer-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, string>;
Expand Down Expand Up @@ -181,6 +183,8 @@ export function ComposerPreviewBody({
url: string | null;
className?: string;
}) {
const commitLink = url ? commitLinkTo(url) : null;

return (
<div
id={preview.previewId}
Expand Down Expand Up @@ -217,16 +221,26 @@ export function ComposerPreviewBody({
className="mt-3 flex items-center gap-3 rounded-lg border border-dashed px-3 py-2"
data-test="link-card-preview"
>
<Link2
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
{commitLink ? (
<AppLogoIcon
aria-hidden
className="size-4 shrink-0 fill-foreground"
/>
) : (
<Link2
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
)}
<div className="min-w-0">
<p className="truncate font-mono text-[13px]">
{url.replace(/^https?:\/\//i, '')}
{commitLink
? `${commitLink.handle}/${shortHash(commitLink.commit)}`
: url.replace(/^https?:\/\//i, '')}
</p>
<p className="text-sm leading-snug text-muted-foreground">
Unfurls into a link card after you commit.
Unfurls into a {commitLink ? 'commit' : 'link'} card
after you commit.
</p>
</div>
</div>
Expand Down
114 changes: 90 additions & 24 deletions resources/js/components/link-preview.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -78,26 +85,35 @@ function useFetchedLinkPreview(
return { preview: current, isWaiting: current.is_pending && !hasGivenUp };
}

function LinkPreviewSkeleton() {
function LinkPreviewSkeleton({ isCommit }: { isCommit: boolean }) {
return (
<div
aria-hidden
className="mt-3 flex items-center overflow-hidden rounded-lg border"
data-test="link-preview-skeleton"
>
<Skeleton className="my-2 ml-2 size-20 shrink-0" />
{!isCommit && <Skeleton className="my-2 ml-2 size-20 shrink-0" />}
<div className="flex min-w-0 flex-1 flex-col justify-center px-3 py-2">
<span className="flex h-lh items-center text-[13px]">
<Skeleton className="h-3 w-24" />
</span>
<span className="flex h-[2lh] flex-col justify-around text-[15px] leading-snug sm:h-lh">
<Skeleton className="h-3.5 w-full sm:w-2/3" />
<Skeleton className="h-3.5 w-2/3 sm:hidden" />
</span>
<span className="mt-0.5 flex h-[2lh] flex-col justify-around text-sm leading-snug">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-1/2" />
</span>
{isCommit ? (
<span className="mt-0.5 flex h-[2lh] flex-col justify-around text-[15px] leading-snug">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-1/2" />
</span>
) : (
<>
<span className="flex h-[2lh] flex-col justify-around text-[15px] leading-snug sm:h-lh">
<Skeleton className="h-3.5 w-full sm:w-2/3" />
<Skeleton className="h-3.5 w-2/3 sm:hidden" />
</span>
<span className="mt-0.5 flex h-[2lh] flex-col justify-around text-sm leading-snug">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-1/2" />
</span>
</>
)}
</div>
</div>
);
Expand All @@ -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 (
Expand All @@ -130,7 +151,7 @@ export function LinkPreview({
}

if (isWaiting) {
return <LinkPreviewSkeleton />;
return <LinkPreviewSkeleton isCommit={commitLink !== null} />;
}

if (!preview.title) {
Expand All @@ -151,21 +172,50 @@ export function LinkPreview({
)}

<div className="flex min-w-0 flex-1 flex-col justify-center px-3 py-2">
<span className="truncate font-mono text-[13px] text-muted-foreground">
{preview.site_name ?? hostname(preview.url)}
</span>
<span className="line-clamp-2 text-[15px] leading-snug font-medium wrap-break-word">
{preview.title}
</span>
{preview.description && (
<span className="mt-0.5 line-clamp-2 text-sm leading-snug wrap-break-word text-muted-foreground">
{preview.description}
</span>
{commitLink ? (
<>
<span className="flex items-center gap-1.5 font-mono text-[13px] text-muted-foreground">
<AppLogoIcon
aria-hidden
className="size-[1.15em] shrink-0 fill-foreground"
/>
<span className="sr-only">
@{commitLink.handle}’s commit{' '}
{shortHash(commitLink.commit)}
</span>
<span aria-hidden className="truncate">
{commitLink.handle}/
{shortHash(commitLink.commit)}
</span>
</span>
{preview.description && (
<span className="mt-0.5 line-clamp-3 text-[15px] leading-snug wrap-break-word">
{preview.description}
</span>
)}
</>
) : (
<>
<span className="truncate font-mono text-[13px] text-muted-foreground">
{preview.site_name ?? hostname(preview.url)}
</span>
<span className="line-clamp-2 text-[15px] leading-snug font-medium wrap-break-word">
{preview.title}
</span>
{preview.description && (
<span className="mt-0.5 line-clamp-2 text-sm leading-snug wrap-break-word text-muted-foreground">
{preview.description}
</span>
)}
</>
)}
</div>
</>
);

const linkClassName =
'mt-3 flex items-center overflow-hidden rounded-lg border transition-colors hover:bg-accent focus-visible:bg-accent';

if (isStatic) {
return (
<div
Expand All @@ -177,13 +227,29 @@ export function LinkPreview({
);
}

if (commitLink) {
return (
<Link
href={CommitController.show({
user: commitLink.handle,
commit: commitLink.commit,
})}
onClick={(event) => event.stopPropagation()}
className={linkClassName}
data-test="link-preview"
>
{content}
</Link>
);
}

return (
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer nofollow ugc"
onClick={(event) => 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}
Expand Down
10 changes: 4 additions & 6 deletions resources/js/components/linkified-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}$/;
Expand Down Expand Up @@ -756,7 +754,7 @@ export function LinkifiedText({
continue;
}

const commitLink = commitLinkPattern.exec(linkText);
const commitLink = commitLinkTo(linkText);
const forgeLink = forgeLinkTo(href);

parts.push(
Expand All @@ -772,8 +770,8 @@ export function LinkifiedText({
<CommitLink
key={match.index}
url={linkText}
handle={commitLink[1]}
commit={commitLink[2].toLowerCase()}
handle={commitLink.handle}
commit={commitLink.commit}
isStatic={isStatic}
/>
) : forgeLink ? (
Expand Down
Loading