mirror of
https://github.com/better-auth/better-hub
synced 2026-08-09 12:26:04 +00:00
feat: improve issue timeline UI (#225)
This commit is contained in:
@@ -70,15 +70,15 @@ export default async function IssueDetailPage({
|
||||
const issueNumber = parseInt(numStr, 10);
|
||||
|
||||
const hdrs = await headers();
|
||||
const [issue, rawComments, repoData, crossRefs, timelineEvents, currentUser, session] =
|
||||
const [issue, rawComments, repoData, crossRefs, currentUser, session, timelineEvents] =
|
||||
await Promise.all([
|
||||
getIssue(owner, repo, issueNumber),
|
||||
getIssueComments(owner, repo, issueNumber),
|
||||
getRepo(owner, repo),
|
||||
getCrossReferences(owner, repo, issueNumber),
|
||||
getIssueTimelineEvents(owner, repo, issueNumber),
|
||||
getAuthenticatedUser(),
|
||||
auth.api.getSession({ headers: hdrs }),
|
||||
getIssueTimelineEvents(owner, repo, issueNumber),
|
||||
]);
|
||||
const comments = rawComments as IssueComment[];
|
||||
|
||||
@@ -235,10 +235,6 @@ export default async function IssueDetailPage({
|
||||
currentUserLogin={currentUserLogin}
|
||||
viewerHasWriteAccess={viewerHasWriteAccess}
|
||||
timelineEvents={timelineEvents}
|
||||
issueStateReason={
|
||||
(issue as { state_reason?: string | null })
|
||||
.state_reason
|
||||
}
|
||||
/>
|
||||
}
|
||||
commentForm={
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Pencil, X, Loader2, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
|
||||
import { MarkdownMentionTooltips } from "@/components/shared/markdown-mention-tooltips";
|
||||
import { CollapsibleBody } from "@/components/issue/collapsible-body";
|
||||
import { ReactionDisplay, type Reactions } from "@/components/shared/reaction-display";
|
||||
import { UserTooltip } from "@/components/shared/user-tooltip";
|
||||
@@ -46,10 +47,12 @@ export function EditableIssueDescription({
|
||||
|
||||
const renderedBody = entry.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
<MarkdownMentionTooltips>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
</MarkdownMentionTooltips>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchIssueComments } from "@/app/(app)/repos/[owner]/[repo]/issues/issue-actions";
|
||||
import {
|
||||
@@ -34,6 +35,70 @@ function toEntries(comments: IssueComment[]): IssueCommentEntry[] {
|
||||
}));
|
||||
}
|
||||
|
||||
type TimelineSegment =
|
||||
| { type: "entries"; entries: IssueTimelineEntry[] }
|
||||
| { type: "events"; events: IssueTimelineEvent[] };
|
||||
|
||||
function interleaveTimelineAndComments(
|
||||
descriptionEntry: IssueDescriptionEntry,
|
||||
comments: IssueComment[],
|
||||
timelineEvents: IssueTimelineEvent[] = [],
|
||||
): TimelineSegment[] {
|
||||
const commentEntries = toEntries(comments);
|
||||
|
||||
const allItems: Array<
|
||||
| { kind: "entry"; item: IssueTimelineEntry; date: Date }
|
||||
| { kind: "event"; item: IssueTimelineEvent; date: Date }
|
||||
> = [
|
||||
{
|
||||
kind: "entry",
|
||||
item: descriptionEntry,
|
||||
date: new Date(descriptionEntry.created_at),
|
||||
},
|
||||
...commentEntries.map((entry) => ({
|
||||
kind: "entry" as const,
|
||||
item: entry,
|
||||
date: new Date(entry.created_at),
|
||||
})),
|
||||
...timelineEvents.map((event) => ({
|
||||
kind: "event" as const,
|
||||
item: event,
|
||||
date: new Date(event.created_at),
|
||||
})),
|
||||
];
|
||||
|
||||
allItems.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
const segments: TimelineSegment[] = [];
|
||||
let currentEntries: IssueTimelineEntry[] = [];
|
||||
let currentEvents: IssueTimelineEvent[] = [];
|
||||
|
||||
for (const item of allItems) {
|
||||
if (item.kind === "entry") {
|
||||
if (currentEvents.length > 0) {
|
||||
segments.push({ type: "events", events: currentEvents });
|
||||
currentEvents = [];
|
||||
}
|
||||
currentEntries.push(item.item);
|
||||
} else {
|
||||
if (currentEntries.length > 0) {
|
||||
segments.push({ type: "entries", entries: currentEntries });
|
||||
currentEntries = [];
|
||||
}
|
||||
currentEvents.push(item.item);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEntries.length > 0) {
|
||||
segments.push({ type: "entries", entries: currentEntries });
|
||||
}
|
||||
if (currentEvents.length > 0) {
|
||||
segments.push({ type: "events", events: currentEvents });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function IssueCommentsClient({
|
||||
owner,
|
||||
repo,
|
||||
@@ -45,7 +110,6 @@ export function IssueCommentsClient({
|
||||
currentUserLogin,
|
||||
viewerHasWriteAccess,
|
||||
timelineEvents,
|
||||
issueStateReason,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
@@ -57,7 +121,6 @@ export function IssueCommentsClient({
|
||||
currentUserLogin?: string;
|
||||
viewerHasWriteAccess?: boolean;
|
||||
timelineEvents?: IssueTimelineEvent[];
|
||||
issueStateReason?: string | null;
|
||||
}) {
|
||||
const { data: comments = initialComments } = useQuery({
|
||||
queryKey: ["issue-comments", owner, repo, issueNumber],
|
||||
@@ -68,28 +131,38 @@ export function IssueCommentsClient({
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
const entries: IssueTimelineEntry[] = [descriptionEntry, ...toEntries(comments)];
|
||||
const segments = useMemo(
|
||||
() => interleaveTimelineAndComments(descriptionEntry, comments, timelineEvents),
|
||||
[descriptionEntry, comments, timelineEvents],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueConversation
|
||||
entries={entries}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
issueNumber={issueNumber}
|
||||
canEdit={canEdit}
|
||||
issueTitle={issueTitle}
|
||||
currentUserLogin={currentUserLogin}
|
||||
viewerHasWriteAccess={viewerHasWriteAccess}
|
||||
/>
|
||||
{timelineEvents && timelineEvents.length > 0 && (
|
||||
<IssueTimelineEvents
|
||||
events={timelineEvents}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
issueStateReason={issueStateReason}
|
||||
/>
|
||||
)}
|
||||
{segments.map((segment, idx) => {
|
||||
const isLastSegment = idx === segments.length - 1;
|
||||
return segment.type === "entries" ? (
|
||||
<IssueConversation
|
||||
key={`entries-${idx}`}
|
||||
entries={segment.entries}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
issueNumber={issueNumber}
|
||||
canEdit={canEdit}
|
||||
issueTitle={issueTitle}
|
||||
currentUserLogin={currentUserLogin}
|
||||
viewerHasWriteAccess={viewerHasWriteAccess}
|
||||
hasMoreAfter={!isLastSegment}
|
||||
/>
|
||||
) : (
|
||||
<IssueTimelineEvents
|
||||
key={`events-${idx}`}
|
||||
events={segment.events}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
isLastSegment={isLastSegment}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Image from "next/image";
|
||||
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
|
||||
import { EditableIssueDescription } from "@/components/issue/editable-issue-description";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
import { MarkdownMentionTooltips } from "@/components/shared/markdown-mention-tooltips";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
import { BotActivityGroup } from "@/components/pr/bot-activity-group";
|
||||
@@ -114,6 +115,7 @@ export function IssueConversation({
|
||||
issueTitle,
|
||||
currentUserLogin,
|
||||
viewerHasWriteAccess,
|
||||
hasMoreAfter = false,
|
||||
}: {
|
||||
entries: IssueTimelineEntry[];
|
||||
owner: string;
|
||||
@@ -123,13 +125,15 @@ export function IssueConversation({
|
||||
issueTitle?: string;
|
||||
currentUserLogin?: string;
|
||||
viewerHasWriteAccess?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
}) {
|
||||
const grouped = groupEntries(entries);
|
||||
const showLine = grouped.length > 1 || hasMoreAfter;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Timeline connector line */}
|
||||
{grouped.length > 1 && (
|
||||
{showLine && (
|
||||
<div className="absolute left-[19px] top-10 bottom-4 w-px bg-border/50" />
|
||||
)}
|
||||
|
||||
@@ -314,10 +318,12 @@ function ThreadEntry({
|
||||
const renderedBody = entry.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
<MarkdownMentionTooltips>
|
||||
<div
|
||||
className="ghmd"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
</MarkdownMentionTooltips>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
@@ -565,10 +571,12 @@ function ThreadComment({
|
||||
const renderedBody = entry.bodyHtml ? (
|
||||
<MarkdownCopyHandler>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className="ghmd ghmd-sm"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
<MarkdownMentionTooltips>
|
||||
<div
|
||||
className="ghmd ghmd-sm"
|
||||
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
|
||||
/>
|
||||
</MarkdownMentionTooltips>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
) : null;
|
||||
|
||||
@@ -3,7 +3,6 @@ import Image from "next/image";
|
||||
import { CircleDot, CheckCircle2, GitPullRequest, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
import { CopyLinkButton } from "@/components/shared/copy-link-button";
|
||||
import { PinButton } from "@/components/shared/pin-button";
|
||||
import type { CrossReference } from "@/lib/github";
|
||||
import { UserTooltip } from "@/components/shared/user-tooltip";
|
||||
@@ -90,12 +89,6 @@ export function IssueHeader({
|
||||
<span className="text-[11px] text-muted-foreground/50 font-mono">
|
||||
{commentsCount} comment{commentsCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<CopyLinkButton
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
number={number}
|
||||
type="issues"
|
||||
/>
|
||||
<PinButton
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
|
||||
@@ -10,14 +10,14 @@ interface IssueTimelineEventsProps {
|
||||
events: IssueTimelineEvent[];
|
||||
owner: string;
|
||||
repo: string;
|
||||
issueStateReason?: string | null;
|
||||
isLastSegment?: boolean;
|
||||
}
|
||||
|
||||
export function IssueTimelineEvents({
|
||||
events,
|
||||
owner,
|
||||
repo,
|
||||
issueStateReason,
|
||||
isLastSegment = false,
|
||||
}: IssueTimelineEventsProps) {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
@@ -26,17 +26,26 @@ export function IssueTimelineEvents({
|
||||
.map((e) => e.source!);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 py-3 ml-16">
|
||||
{events.map((event) => (
|
||||
<TimelineEventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
linkedPRs={crossRefPRs}
|
||||
issueStateReason={issueStateReason}
|
||||
/>
|
||||
))}
|
||||
<div className="relative py-1 -mt-2 mb-2">
|
||||
{/* Timeline connector line - extends beyond bounds to connect with adjacent segments */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-[19px] -top-5 w-px bg-border/50",
|
||||
isLastSegment ? "bottom-0" : "-bottom-4",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1 pl-[52px] py-2">
|
||||
{events.map((event) => (
|
||||
<TimelineEventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
linkedPRs={crossRefPRs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { UserTooltip } from "@/components/shared/user-tooltip";
|
||||
|
||||
interface MentionData {
|
||||
element: HTMLAnchorElement;
|
||||
username: string;
|
||||
wrapper: HTMLSpanElement;
|
||||
}
|
||||
|
||||
function MentionTooltipPortal({ mention }: { mention: MentionData }) {
|
||||
const [children, setChildren] = useState<React.ReactNode>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setChildren(
|
||||
<span dangerouslySetInnerHTML={{ __html: mention.element.innerHTML }} />,
|
||||
);
|
||||
}, [mention.element]);
|
||||
|
||||
if (!children) return null;
|
||||
|
||||
return createPortal(
|
||||
<UserTooltip username={mention.username} side="top">
|
||||
<a
|
||||
href={mention.element.href}
|
||||
className={mention.element.className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</UserTooltip>,
|
||||
mention.wrapper,
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownMentionTooltips({ children }: { children: React.ReactNode }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mentions, setMentions] = useState<MentionData[]>([]);
|
||||
const mentionsRef = useRef<MentionData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const mentionLinks =
|
||||
container.querySelectorAll<HTMLAnchorElement>("a.ghmd-mention");
|
||||
if (mentionLinks.length === 0) return;
|
||||
|
||||
const newMentions: MentionData[] = [];
|
||||
|
||||
for (const link of mentionLinks) {
|
||||
const href = link.getAttribute("href") || "";
|
||||
const usernameMatch = href.match(/\/users\/([^/]+)/);
|
||||
if (!usernameMatch) continue;
|
||||
|
||||
const username = usernameMatch[1];
|
||||
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.style.display = "inline";
|
||||
link.parentNode?.insertBefore(wrapper, link);
|
||||
link.style.display = "none";
|
||||
|
||||
newMentions.push({
|
||||
element: link,
|
||||
username,
|
||||
wrapper,
|
||||
});
|
||||
}
|
||||
|
||||
mentionsRef.current = newMentions;
|
||||
setMentions(newMentions);
|
||||
|
||||
return () => {
|
||||
for (const mention of mentionsRef.current) {
|
||||
mention.element.style.display = "";
|
||||
mention.wrapper.remove();
|
||||
}
|
||||
mentionsRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{children}
|
||||
{mentions.map((mention, idx) => (
|
||||
<MentionTooltipPortal key={idx} mention={mention} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { highlightCode } from "@/lib/shiki";
|
||||
import { toInternalUrl } from "@/lib/github-utils";
|
||||
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
|
||||
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
|
||||
import { MarkdownMentionTooltips } from "@/components/shared/markdown-mention-tooltips";
|
||||
|
||||
interface RepoContext {
|
||||
owner: string;
|
||||
@@ -500,10 +501,12 @@ export async function MarkdownRenderer({
|
||||
return (
|
||||
<MarkdownCopyHandler>
|
||||
<ReactiveCodeBlocks>
|
||||
<div
|
||||
className={`ghmd ${className || ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<MarkdownMentionTooltips>
|
||||
<div
|
||||
className={`ghmd ${className || ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</MarkdownMentionTooltips>
|
||||
</ReactiveCodeBlocks>
|
||||
</MarkdownCopyHandler>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user