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
134 changes: 134 additions & 0 deletions PERFORMANCE.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion resources/js/components/commit-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function CommitCard({
initialReplies,
threadActions,
showImages = true,
prioritizeImage = false,
loadsReplies = false,
endsWithDot = false,
railNodePosition = 'top',
Expand All @@ -110,6 +111,7 @@ export function CommitCard({
initialReplies?: CursorPaginated<ThreadCommit>;
threadActions?: ReactNode;
showImages?: boolean;
prioritizeImage?: boolean;
loadsReplies?: boolean;
endsWithDot?: boolean;
railNodePosition?: 'top' | 'center' | 'bottom' | 'author';
Expand Down Expand Up @@ -469,7 +471,12 @@ export function CommitCard({
/>
</div>
{translation.notice}
{showImages && <CommitImages commit={commit} />}
{showImages && (
<CommitImages
commit={commit}
priority={prioritizeImage}
/>
)}
{commit.poll && (
<CommitPoll
key={`${commit.poll.id}-${commit.poll.has_voted}-${commit.poll.total_votes}`}
Expand Down
6 changes: 6 additions & 0 deletions resources/js/components/commit-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ export function CommitFeed({
const card = (commit: Commit) => (
<CommitCard
commit={commit}
prioritizeImage={
page.component === 'log/show' &&
pinnedCommit === null &&
commit.id === listed[0]?.id
}
endsWithDot
isDimmed={isDimmed(commit)}
isHighlighted={
Expand Down Expand Up @@ -230,6 +235,7 @@ export function CommitFeed({
</div>
<CommitCard
commit={pinnedCommit}
prioritizeImage={page.component === 'log/show'}
endsWithDot
isDimmed={isDimmed(pinnedCommit)}
onHover={(isHovering, event) =>
Expand Down
16 changes: 11 additions & 5 deletions resources/js/components/commit-images.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,19 @@ function CommitImageThumbnail({
alt,
className,
isSingle,
priority = false,
}: {
image: ShownImage;
alt: string;
className: string;
isSingle: boolean;
priority?: boolean;
}) {
const [isLoaded, setIsLoaded] = useState(false);

return (
<>
{!isLoaded && (
{!priority && !isLoaded && (
<CommitImageLoading
hash={image.id.replace(/[^0-9a-f]/g, '').slice(-7)}
/>
Expand All @@ -105,15 +107,16 @@ function CommitImageThumbnail({
alt={alt}
width={image.width}
height={image.height}
loading="lazy"
loading={priority ? 'eager' : 'lazy'}
fetchPriority={priority ? 'high' : undefined}
decoding="async"
data-single-image={isSingle || undefined}
onLoad={() => setIsLoaded(true)}
onError={(event) => replaceFailedImage(event.currentTarget)}
className={cn(
className,
'transition-opacity duration-300',
!isLoaded && 'opacity-0',
!priority && !isLoaded && 'opacity-0',
)}
/>
</>
Expand All @@ -123,13 +126,14 @@ function CommitImageThumbnail({
/**
* A commit's images open in its photo viewer. Other images, like a message's, open wherever `onOpen` shows them.
*/
type CommitImagesProps =
type CommitImagesProps = { priority?: boolean } & (
| { commit: Commit; images?: never; onOpen?: never }
| {
commit?: never;
images: ShownImage[];
onOpen?: (index: number) => void;
};
}
);

const isModifiedClick = (event: MouseEvent): boolean =>
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
Expand All @@ -138,6 +142,7 @@ export function CommitImages({
commit,
images: draftImages,
onOpen,
priority = false,
}: CommitImagesProps) {
const images = commit ? (commit.images ?? []) : draftImages;

Expand Down Expand Up @@ -167,6 +172,7 @@ export function CommitImages({
const thumbnail = (
<CommitImageThumbnail
image={image}
priority={priority && index === 0}
isSingle={!hasMultipleImages}
alt={commitImageAlt(image, index, images.length)}
className={cn(
Expand Down
42 changes: 37 additions & 5 deletions resources/js/hooks/use-now.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,41 @@ const listeners = new Set<() => void>();
let now = Date.now();
let interval: ReturnType<typeof setInterval> | undefined;

function tick(): void {
now = Date.now();
listeners.forEach((notify) => notify());
}

function start(): void {
if (
interval === undefined &&
(typeof document === 'undefined' || !document.hidden)
) {
interval = setInterval(tick, 30_000);
}
}

function visibilityChanged(): void {
if (document.hidden) {
clearInterval(interval);
interval = undefined;
} else {
// Refresh immediately on return rather than waiting for the next half-minute tick.
tick();
start();
}
}

const subscribe = (listener: () => void) => {
const wasEmpty = listeners.size === 0;
listeners.add(listener);

if (interval === undefined) {
if (wasEmpty) {
now = Date.now();
interval = setInterval(() => {
now = Date.now();
listeners.forEach((notify) => notify());
}, 30_000);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', visibilityChanged);
}
start();
}

return () => {
Expand All @@ -21,6 +47,12 @@ const subscribe = (listener: () => void) => {
if (listeners.size === 0) {
clearInterval(interval);
interval = undefined;
if (typeof document !== 'undefined') {
document.removeEventListener(
'visibilitychange',
visibilityChanged,
);
}
}
};
};
Expand Down
12 changes: 12 additions & 0 deletions resources/js/lib/certificate-hashes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,20 @@ export const certificateHashPattern = new RegExp(
'gi',
);

const certificateHashCandidate = new RegExp(certificateHashPattern.source, 'i');

/** Decorates only text in already escaped, server-highlighted code, never HTML attributes. */
export function withCertificateHashes(html: string): string {
if (!html.includes('<')) {
return html.replace(
certificateHashPattern,
'<span class="certificate-hash"><span>$1</span></span>',
);
}

// Most highlighted code has no compromised fingerprint: avoid splitting all its tags.
if (!certificateHashCandidate.test(html)) return html;

return html
.split(/(<[^>]*>)/g)
.map((part, index) =>
Expand Down
9 changes: 8 additions & 1 deletion resources/js/lib/chat-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ export function isBehindLatest(
scroller: HTMLElement,
messages: Message[],
): boolean {
const boundary = messages.filter((message) => !message.event).at(-25);
let remaining = 25;
let boundary: Message | undefined;
for (let index = messages.length - 1; index >= 0; index--) {
if (!messages[index].event && --remaining === 0) {
boundary = messages[index];
break;
}
}
const bubble = boundary
? scroller.querySelector<HTMLElement>(
`[data-scroll-anchor="${CSS.escape(boundary.client_id)}"]`,
Expand Down
10 changes: 6 additions & 4 deletions resources/js/lib/conversation-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function conversationsMatching(
const isPersonMatch = ({ handle, name }: User) =>
isMatch(handle) || isMatch(name);

const seen = new Set<string>();
return [
...found,
...onHand.filter(
Expand All @@ -26,8 +27,9 @@ export function conversationsMatching(
isPersonMatch(user),
),
),
].filter(
(conversation, index, all) =>
all.findIndex(({ id }) => id === conversation.id) === index,
);
].filter((conversation) => {
if (seen.has(conversation.id)) return false;
seen.add(conversation.id);
return true;
});
}
40 changes: 23 additions & 17 deletions resources/js/lib/emoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export type EmojiSuggestion = {
emoji: string;
};

const searchableShortcodes = Object.entries(shortcodeEmoji).map(
([name, emoji]) => ({
name,
emoji,
words: name.split(/[_-]/),
}),
);

const emojiNames = new Map<string, string>();

Object.entries(shortcodeEmoji).forEach(([name, emoji]) => {
Expand All @@ -57,12 +65,12 @@ export function emojiName(emoji: string): string | undefined {
/**
* 0 when the shortcode starts with the query, 1 when a later word does, 2 when it only contains it.
*/
function matchRank(name: string, partial: string): number {
function matchRank(name: string, words: string[], partial: string): number {
if (name.startsWith(partial)) {
return 0;
}

return name.split(/[_-]/).some((word) => word.startsWith(partial)) ? 1 : 2;
return words.some((word) => word.startsWith(partial)) ? 1 : 2;
}

/**
Expand All @@ -80,21 +88,19 @@ export function searchEmoji(
.replace(/^:+|:+$/g, '');
const best = new Map<string, { name: string; rank: number }>();

Object.keys(shortcodeEmoji)
.filter((name) => name.includes(partial))
.forEach((name) => {
const emoji = shortcodeEmoji[name];
const rank = matchRank(name, partial);
const current = best.get(emoji);

if (
current === undefined ||
rank < current.rank ||
(rank === current.rank && name.length < current.name.length)
) {
best.set(emoji, { name, rank });
}
});
for (const { name, emoji, words } of searchableShortcodes) {
if (!name.includes(partial)) continue;
const rank = matchRank(name, words, partial);
const current = best.get(emoji);

if (
current === undefined ||
rank < current.rank ||
(rank === current.rank && name.length < current.name.length)
) {
best.set(emoji, { name, rank });
}
}

return [...best.entries()]
.sort(
Expand Down
64 changes: 47 additions & 17 deletions resources/js/lib/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export function graphEdges(
const byId = new Map(nodes.map((node) => [node.id, node]));
const edges: GraphEdge[] = [];

let branches: ReturnType<typeof branchNeighbors> | undefined;

for (const node of nodes) {
const previous =
node.after === undefined ? undefined : byId.get(node.after);
Expand All @@ -62,23 +64,11 @@ export function graphEdges(
node.parent === undefined ? undefined : byId.get(node.parent);

if (parent) {
const continues = nodes.some(
(other) =>
other.after === parent.id &&
other.y > node.y &&
!drawing.has(trunkId(parent, other)),
);
const above = nodes
.filter(
(other) => other.parent === parent.id && other.y < node.y,
)
.reduce<GraphNode | undefined>(
(closest, other) =>
closest === undefined || other.y > closest.y
? other
: closest,
undefined,
);
branches ??= branchNeighbors(nodes, drawing);
const { continuationBottom, aboveNode } = branches;
const continues =
(continuationBottom.get(parent.id) ?? -Infinity) > node.y;
const above = aboveNode.get(node);

edges.push(
branch(
Expand All @@ -97,6 +87,46 @@ export function graphEdges(
return edges;
}

/** Lazily built: a canvas with trunks alone needs no branch indexes. */
function branchNeighbors(nodes: GraphNode[], drawing: ReadonlySet<string>) {
// Index each parent's column once rather than scanning the whole canvas per reply.
const continuationBottom = new Map<string, number>();
const siblings = new Map<string, GraphNode[]>();
for (const node of nodes) {
if (
node.after !== undefined &&
!drawing.has(`${node.after}->${node.id}`)
) {
const bottom = continuationBottom.get(node.after) ?? -Infinity;
if (node.y > bottom) continuationBottom.set(node.after, node.y);
}
if (node.parent !== undefined && !Number.isNaN(node.y)) {
const group = siblings.get(node.parent);
if (group) group.push(node);
else siblings.set(node.parent, [node]);
}
}

const aboveNode = new Map<GraphNode, GraphNode>();
for (const group of siblings.values()) {
group.sort((a, b) => a.y - b.y);
let above: GraphNode | undefined;
for (let index = 0; index < group.length;) {
const first = group[index];
let next = index;
// Equal-height siblings share the previous row. Keep input-order tie breaking.
while (next < group.length && group[next].y === first.y) {
if (above) aboveNode.set(group[next], above);
next++;
}
above = first;
index = next;
}
}

return { continuationBottom, aboveNode };
}

/**
* An animation running on a node or around it: how long it has left, and, while it waits to start, how long until it
* does. A node that trails its line, like an end dot, only pops once the line is drawn.
Expand Down
Loading