diff --git a/.ai/guidelines/resources.js.blade.php b/.ai/guidelines/resources.js.blade.php index 8abeddea7..0468cc909 100644 --- a/.ai/guidelines/resources.js.blade.php +++ b/.ai/guidelines/resources.js.blade.php @@ -17,7 +17,7 @@ ## Navigation and requests - Every Inertia `` to a GET page prefetches on hover (`prefetch`). A GET page never writes; the only exceptions are `log/{log}` (remembers the log), `notifications` (marks read, remembers the filter), `remotes/@{partner}` and `remotes/orgs/{org}` (mark read), and every link to them keeps `prefetch={false}`. `starred` also keeps it, because starring changes the list. A new GET page that must write joins this list. -- Never prefetch a non-GET link, an external URL, a guest's sign-in link, or a URL that is not an Inertia page (images, feeds, downloads, JSON). +- Never prefetch a non-GET link, an external URL, a guest's sign-in link, a mention of a handle nobody here has (guests get a missing page, and a feed would ask for it on every hover), or a URL that is not an Inertia page (images, feeds, downloads, JSON). - A click target that is not a link (the commit card surface) prefetches on `mousedown`, never on hover: scrolling a feed hovers every card. - After an action that changes what a prefetched page shows, flush that page (`router.flush(url)`, `router.flushAll()`, or `invalidateCacheTags`), so the next click does not show the old state. - One user intent is one request. When the server would only echo the URL back (a search query, a filter), change it on the client with `router.replace({ url, props })`. Data the first paint needs comes with the page as a prop or a deferred prop, never from a fetch in a mount effect. diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 494804736..ba0625773 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -247,7 +247,7 @@ The smiley button in the commit and reply composers opens the same searchable em In commits, replies, and remotes, start a line with `>` to render a quote block. Consecutive quoted lines stay together; an unquoted line ends the block. Quotes keep inline code, links, mentions, and emoji formatting. A `>` inside code or in the middle of a line stays literal. -Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Mentioning a GitHub organization, like `@laravel`, links to its [organization page](#organization-pages) and notifies its members: a known organization links right away, one mentioned for the first time once the commit is approved and GitHub confirms it is an organization. Typing @ followed by a few letters also suggests organizations that already have a page, after developers. Organization mentions only link in commits and replies, not in remotes. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. +Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Mentioning a GitHub organization, like `@laravel`, links to its [organization page](#organization-pages) and notifies its members: a known organization links right away, one mentioned for the first time once the commit is approved and GitHub confirms it is an organization. Typing @ followed by a few letters also suggests organizations that already have a page, after developers. Organization mentions only link in commits and replies, not in remotes. In commits and replies, but not in remotes, a mention of anyone else links to `/@handle` too, where signed-in members can [look them up on GitHub](#developers-not-on-devnull-yet); it never notifies anyone who isn't on /dev/null. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. Typing **#123** in a composer looks up that exact issue or pull request in the /dev/null repository, `dev-null-sh/dev-null.sh`. The matching title appears beneath the number; click it or use Enter or Tab to insert its GitHub URL, just like choosing an emoji suggestion. A space or punctuation leaves the number as typed and dismisses the suggestion. Missing numbers and failed lookups leave the text unchanged. Code, URL fragments, and references qualified with another repository are not expanded. diff --git a/resources/js/components/chat/message-parts.tsx b/resources/js/components/chat/message-parts.tsx index 6bfb79917..1c12d6c99 100644 --- a/resources/js/components/chat/message-parts.tsx +++ b/resources/js/components/chat/message-parts.tsx @@ -47,6 +47,7 @@ export function MessageText({ ); diff --git a/resources/js/components/commit-body.tsx b/resources/js/components/commit-body.tsx index a43354dc4..1136c2c53 100644 --- a/resources/js/components/commit-body.tsx +++ b/resources/js/components/commit-body.tsx @@ -14,12 +14,17 @@ export function CommitBody({ organizationMentions = null, className, isStatic = false, + linksEveryMention = true, }: { segments: BodySegment[]; mentions: Commit['mentions']; organizationMentions?: Commit['organization_mentions']; className?: string; isStatic?: boolean; + /** + * Whether a mention of anyone else links to their page too, as in commits, or stays text, as in remotes. + */ + linksEveryMention?: boolean; }) { return (
) : ( @@ -49,6 +55,7 @@ export function CommitBody({ mentions={mentions} organizationMentions={organizationMentions} isStatic={isStatic} + linksEveryMention={linksEveryMention} /> ), )} @@ -61,11 +68,13 @@ export function BodySegments({ mentions, organizationMentions = null, isStatic = false, + linksEveryMention = true, }: { segments: BodySegment[]; mentions: Commit['mentions']; organizationMentions?: Commit['organization_mentions']; isStatic?: boolean; + linksEveryMention?: boolean; }) { const { auth } = usePage().props; @@ -82,6 +91,7 @@ export function BodySegments({ organizationMentions={organizationMentions} pasteisMode={Boolean(auth.pasteis)} isStatic={isStatic} + linksEveryMention={linksEveryMention} /> ); case 'code': diff --git a/resources/js/components/linkified-text.tsx b/resources/js/components/linkified-text.tsx index 12df12e59..9b4f49069 100644 --- a/resources/js/components/linkified-text.tsx +++ b/resources/js/components/linkified-text.tsx @@ -2,6 +2,7 @@ import { Link } from '@inertiajs/react'; import { Fragment } from 'react'; import type { ReactNode, SVGAttributes } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import { show as showProfile } from '@/actions/App/Http/Controllers/UserController'; import AppLogoIcon from '@/components/app-logo-icon'; import AppWordmark from '@/components/app-wordmark'; import { CertificateText } from '@/components/certificate-text'; @@ -680,6 +681,7 @@ function withMentions( colors: Color[], pasteisMode: boolean, isStatic: boolean, + linksEveryMention: boolean, ): ReactNode[] { const parts: ReactNode[] = []; let position = 0; @@ -729,8 +731,20 @@ function withMentions( > {match[0]} - ) : ( + ) : !linksEveryMention ? ( match[0] + ) : ( + // Anyone else may be on GitHub: their page looks them up for members. Guests and crawlers get a + // missing page, so it isn't followed, nor prefetched, which would ask for it on every hover of a feed... + + {match[0]} + ), ); @@ -755,6 +769,7 @@ export function LinkifiedText({ organizationMentions = null, pasteisMode = false, isStatic = false, + linksEveryMention = true, startsText = false, }: { text: string; @@ -762,6 +777,7 @@ export function LinkifiedText({ organizationMentions?: Commit['organization_mentions']; pasteisMode?: boolean; isStatic?: boolean; + linksEveryMention?: boolean; // Whether the text is a whole body, so its start is the start of a line. startsText?: boolean; }) { @@ -790,6 +806,7 @@ export function LinkifiedText({ colors, pasteisMode, isStatic, + linksEveryMention, ), commitLink ? ( ]*>rm -rf<\/code>/); assert.ok(markup.includes(`href="/mentions/${mentionedId}"`)); assert.equal(markup.match(/data-test="mention"/g)?.length, 1); - assert.ok(markup.includes('or @bob')); + assert.match(markup, /or ]*href="\/@bob"[^>]*>@bob<\/a>/); }); } diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 36128ef8b..b01b672c2 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -384,7 +384,7 @@ test('links the mentions the server found in the preview', () => { assert.ok(markup.includes('@nobody-here')); }); -test('shows mentions as text until the server has found them', () => { +test('links mentions by handle until the server has found them', () => { const preview = previewFor({ body: 'Pairing with @alice', isSubmittable: true, @@ -394,7 +394,7 @@ test('shows mentions as text until the server has found them', () => { ); assert.equal(preview.mentions, null); - assert.ok(markup.includes('Pairing with @alice')); + assert.match(markup, /Pairing with ]*href="\/@alice"[^>]*>@alice<\/a>/); assert.ok(!markup.includes('data-test="mention"')); }); diff --git a/tests/Unit/LinkifiedText.test.tsx b/tests/Unit/LinkifiedText.test.tsx index c8cd7bd73..d963024a1 100644 --- a/tests/Unit/LinkifiedText.test.tsx +++ b/tests/Unit/LinkifiedText.test.tsx @@ -153,12 +153,27 @@ test('leaves organization mentions as plain text in static bodies', () => { assert.equal(html, 'Thanks @laravel!'); }); -test('leaves mentions without captured identities as plain text', () => { +test('links mentions of anyone else to their page, which looks them up on GitHub', () => { const html = renderToStaticMarkup( - , + , ); - assert.equal(html, 'Thanks @original!'); + assert.equal( + html, + 'Thanks @Original!', + ); +}); + +test('leaves mentions of anyone else as text where only captured ones link, as in remotes', () => { + const html = renderToStaticMarkup( + , + ); + + assert.equal(html, 'Thanks @Original!'); }); test('keeps captured mention identities in regular bodies alongside highlighted code', () => { @@ -182,7 +197,7 @@ test('keeps captured mention identities in regular bodies alongside highlighted assert.ok(html.includes(`href="/mentions/${id}"`)); assert.ok(html.includes('>@ORIGINAL')); assert.equal(html.match(/data-test="mention"/g)?.length, 1); - assert.ok(html.includes('and @unknown!')); + assert.match(html, /and ]*href="\/@unknown"[^>]*>@unknown<\/a>!/); assert.match(html, /]*>@ORIGINAL<\/code>/); assert.match( html,