Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .ai/guidelines/resources.js.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
## Navigation and requests

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

Expand Down
1 change: 1 addition & 0 deletions resources/js/components/chat/message-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function MessageText({
<CommitBody
segments={segments}
mentions={message.mentions ?? null}
linksEveryMention={false}
className={cn('flow-root leading-snug', { [onPrimary]: isMine })}
/>
);
Expand Down
10 changes: 10 additions & 0 deletions resources/js/components/commit-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand All @@ -40,6 +45,7 @@ export function CommitBody({
mentions={mentions}
organizationMentions={organizationMentions}
isStatic={isStatic}
linksEveryMention={linksEveryMention}
/>
</blockquote>
) : (
Expand All @@ -49,6 +55,7 @@ export function CommitBody({
mentions={mentions}
organizationMentions={organizationMentions}
isStatic={isStatic}
linksEveryMention={linksEveryMention}
/>
),
)}
Expand All @@ -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;

Expand All @@ -82,6 +91,7 @@ export function BodySegments({
organizationMentions={organizationMentions}
pasteisMode={Boolean(auth.pasteis)}
isStatic={isStatic}
linksEveryMention={linksEveryMention}
/>
);
case 'code':
Expand Down
20 changes: 19 additions & 1 deletion resources/js/components/linkified-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -680,6 +681,7 @@ function withMentions(
colors: Color[],
pasteisMode: boolean,
isStatic: boolean,
linksEveryMention: boolean,
): ReactNode[] {
const parts: ReactNode[] = [];
let position = 0;
Expand Down Expand Up @@ -729,8 +731,20 @@ function withMentions(
>
{match[0]}
</Link>
) : (
) : !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...
<Link
key={`mention-${offset + match.index}`}
href={showProfile(match[1])}
rel="nofollow"
className="font-medium text-primary hover:underline"
data-test="handle-mention"
>
{match[0]}
</Link>
),
);

Expand All @@ -755,13 +769,15 @@ export function LinkifiedText({
organizationMentions = null,
pasteisMode = false,
isStatic = false,
linksEveryMention = true,
startsText = false,
}: {
text: string;
mentions: Commit['mentions'];
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;
}) {
Expand Down Expand Up @@ -790,6 +806,7 @@ export function LinkifiedText({
colors,
pasteisMode,
isStatic,
linksEveryMention,
),
commitLink ? (
<CommitLink
Expand Down Expand Up @@ -835,6 +852,7 @@ export function LinkifiedText({
colors,
pasteisMode,
isStatic,
linksEveryMention,
),
);

Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/CommitPoll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ for (const results of [false, true]) {
assert.match(markup, /<code[^>]*>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 <a[^>]*href="\/@bob"[^>]*>@bob<\/a>/);
});
}

Expand Down
4 changes: 2 additions & 2 deletions tests/Unit/ComposerPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <a[^>]*href="\/@alice"[^>]*>@alice<\/a>/);
assert.ok(!markup.includes('data-test="mention"'));
});

Expand Down
23 changes: 19 additions & 4 deletions tests/Unit/LinkifiedText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<LinkifiedText text="Thanks @original!" mentions={null} />,
<LinkifiedText text="Thanks @Original!" mentions={null} />,
);

assert.equal(html, 'Thanks @original!');
assert.equal(
html,
'Thanks <a rel="nofollow" class="font-medium text-primary hover:underline" data-test="handle-mention" href="/@Original">@Original</a>!',
);
});

test('leaves mentions of anyone else as text where only captured ones link, as in remotes', () => {
const html = renderToStaticMarkup(
<LinkifiedText
text="Thanks @Original!"
mentions={null}
linksEveryMention={false}
/>,
);

assert.equal(html, 'Thanks @Original!');
});

test('keeps captured mention identities in regular bodies alongside highlighted code', () => {
Expand All @@ -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</a>'));
assert.equal(html.match(/data-test="mention"/g)?.length, 1);
assert.ok(html.includes('and @unknown!'));
assert.match(html, /and <a[^>]*href="\/@unknown"[^>]*>@unknown<\/a>!/);
assert.match(html, /<code[^>]*>@ORIGINAL<\/code>/);
assert.match(
html,
Expand Down
Loading