Files
eranos/src/components/PullRequestCard.tsx
T
Alex Gleason 7b307ffe22 Replace window.open() calls with Capacitor-aware openUrl()
window.open() and target="_blank" silently fail inside WKWebView on
iOS. Replace all programmatic window.open() calls with the openUrl()
utility from src/lib/downloadFile.ts, which uses the native share sheet
on Capacitor and falls back to window.open() on web.

Fixed in: ZapDialog, TasksPanel, HatchTasksPanel, PullRequestCard,
CustomNipCard, GitRepoCard, PatchCard.

The middle-click handler in useOpenPost is left as-is since middle-click
is a web-only interaction with no equivalent on mobile.
2026-03-28 21:25:58 -05:00

223 lines
7.4 KiB
TypeScript

import type { NostrEvent } from "@nostrify/nostrify";
import {
Copy,
GitBranch,
GitCommit,
GitPullRequest,
Wand2,
} from "lucide-react";
import { useState } from "react";
import Markdown from "react-markdown";
import rehypeSanitize from "rehype-sanitize";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { openUrl } from "@/lib/downloadFile";
interface PullRequestCardProps {
event: NostrEvent;
/** If true, show a compact preview. If false, show the full PR content. Defaults to true. */
preview?: boolean;
}
/** Renders a NIP-34 kind 1618 pull request event card. */
export function PullRequestCard({
event,
preview = true,
}: PullRequestCardProps) {
const subject = event.tags.find(([n]) => n === "subject")?.[1];
const branchName = event.tags.find(([n]) => n === "branch-name")?.[1];
const repoTag = event.tags.find(([n]) => n === "a")?.[1];
const repoName = repoTag?.split(":")[2] ?? "";
const cloneUrls = event.tags
.filter(([n]) => n === "clone")
.map(([, v]) => v);
const hasShakespeare = event.tags.some(
([n, v]) => n === "t" && v === "shakespeare",
);
const commitTip = event.tags.find(([n]) => n === "c")?.[1];
const mergeBase = event.tags.find(([n]) => n === "merge-base")?.[1];
const labels = event.tags
.filter(([n]) => n === "t")
.map(([, v]) => v)
.filter((t) => t !== "shakespeare");
const title =
subject || event.content.split("\n")[0]?.trim() || "Pull Request";
const hasDescription = event.content.trim().length > 0;
const [copied, setCopied] = useState(false);
const handleCopy = (url: string) => {
navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const shakespeareUrl = cloneUrls[0]
? `https://shakespeare.diy/clone?url=${encodeURIComponent(cloneUrls[0])}`
: "https://shakespeare.diy";
return (
<div className="mt-2 space-y-3">
{/* Card container */}
<div className="rounded-2xl border border-border overflow-hidden">
<div className="px-3.5 py-3 space-y-2">
{/* Title + branch info */}
<div className="flex items-start gap-2.5">
<GitPullRequest className="size-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<span className="font-semibold text-sm leading-snug line-clamp-2">
{title}
</span>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
{repoName && (
<span className="font-mono">{repoName}</span>
)}
{repoName && branchName && (
<span className="text-muted-foreground/40">
/
</span>
)}
{branchName && (
<Badge
variant="secondary"
className="text-[10px] font-mono px-1.5 py-0 border-0"
>
{branchName}
</Badge>
)}
</div>
</div>
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{labels
.slice(0, preview ? 6 : labels.length)
.map((label) => (
<span
key={label}
className="text-[11px] text-muted-foreground"
>
#{label}
</span>
))}
{preview && labels.length > 6 && (
<span className="text-[11px] text-muted-foreground/50">
+{labels.length - 6} more
</span>
)}
</div>
)}
{/* Clone URL */}
{cloneUrls[0] && (
<div className="group/clone flex items-center gap-2 rounded-lg bg-muted/50 px-2.5 py-1.5">
<code className="flex-1 text-[11px] font-mono text-muted-foreground truncate select-all">
{cloneUrls[0]}
</code>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0 opacity-50 group-hover/clone:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
}}
>
<Copy className="size-3" />
<span className="sr-only">
{copied ? "Copied" : "Copy"}
</span>
</Button>
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{hasShakespeare && (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={(e) => {
e.stopPropagation();
openUrl(shakespeareUrl);
}}
>
<Wand2 className="size-3" />
Edit with Shakespeare
</button>
)}
{!hasShakespeare && cloneUrls[0] && (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary/60"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
}}
>
<Copy className="size-3" />
{copied ? "Copied!" : "Copy Clone URL"}
</button>
)}
</div>
</div>
</div>
{/* Detail sections -- outside card, only in detail view */}
{/* Branch & commit metadata */}
{!preview && (commitTip || mergeBase || branchName) && (
<div className="rounded-2xl border border-border overflow-hidden divide-y divide-border">
{branchName && (
<div className="flex items-center gap-2.5 px-3.5 py-2 text-sm">
<GitBranch className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-muted-foreground text-xs">
Branch
</span>
<code className="ml-auto text-xs font-mono text-muted-foreground">
{branchName}
</code>
</div>
)}
{commitTip && (
<div className="flex items-center gap-2.5 px-3.5 py-2 text-sm">
<GitCommit className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-muted-foreground text-xs">
Tip
</span>
<code className="ml-auto text-xs font-mono text-muted-foreground">
{commitTip.slice(0, 12)}
</code>
</div>
)}
{mergeBase && (
<div className="flex items-center gap-2.5 px-3.5 py-2 text-sm">
<GitCommit className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-muted-foreground text-xs">
Base
</span>
<code className="ml-auto text-xs font-mono text-muted-foreground">
{mergeBase.slice(0, 12)}
</code>
</div>
)}
</div>
)}
{/* PR description */}
{!preview && hasDescription && (
<div className="rounded-2xl border border-border overflow-hidden px-4 py-4 sidebar:px-5 sidebar:py-5">
<div className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
<Markdown rehypePlugins={[rehypeSanitize]}>
{event.content}
</Markdown>
</div>
</div>
)}
</div>
);
}