mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-15 01:46:53 +02:00
feat: basic git stuff rendering
This commit is contained in:
80
TODO.md
80
TODO.md
@@ -95,6 +95,86 @@ When an action is entered, show the list of available options below and provide
|
||||
- **NIP badges everywhere** - Use consistent NIP badge components for linking to NIP documentation
|
||||
- **External spec event kind support** - Add references and documentation links for commented-out event kinds from external specs (Blossom, Marmot Protocol, NKBIP, nostrocket, Corny Chat, NUD, etc.) in `src/constants/kinds.ts`. Consider adding a separate registry or documentation for non-official-NIP event kinds.
|
||||
|
||||
## Code Quality & Refactoring
|
||||
|
||||
### Semantic Component Naming
|
||||
**Priority**: Low | **Effort**: Medium
|
||||
**Files**: All Kind*Renderer.tsx files, EventDetailViewer.tsx, and import locations
|
||||
|
||||
**Current State**:
|
||||
- Component names use technical kind numbers: Kind0DetailRenderer, Kind3DetailView, Kind30023DetailRenderer
|
||||
- Makes code less self-documenting for developers unfamiliar with Nostr kind numbers
|
||||
- Requires mental mapping between kind numbers and their semantic meaning
|
||||
|
||||
**Proposed Renaming**:
|
||||
- `Kind0DetailRenderer` → `ProfileMetadataRenderer` (kind 0)
|
||||
- `Kind0Renderer` → `ProfileMetadataFeedRenderer`
|
||||
- `Kind3DetailView` / `Kind3Renderer` → `ContactListRenderer` (kind 3)
|
||||
- `Kind1621Renderer` / `Kind1621DetailRenderer` → `IssueRenderer` / `IssueDetailRenderer` (kind 1621, NIP-34)
|
||||
- `Kind30023Renderer` / `Kind30023DetailRenderer` → `LongFormArticleRenderer` / `ArticleDetailRenderer` (kind 30023)
|
||||
- `Kind30617Renderer` / `Kind30617DetailRenderer` → `RepositoryRenderer` / `RepositoryDetailRenderer` (kind 30617, NIP-34)
|
||||
- `Kind9802Renderer` / `Kind9802DetailRenderer` → `HighlightRenderer` / `HighlightDetailRenderer` (kind 9802)
|
||||
- `Kind10002Renderer` / `Kind10002DetailRenderer` → `RelayListRenderer` / `RelayListDetailRenderer` (kind 10002)
|
||||
- And all other Kind*Renderer files following this pattern
|
||||
|
||||
**Implementation Tasks**:
|
||||
1. Rename all Kind*Renderer.tsx files to semantic names
|
||||
2. Update component exports and function names
|
||||
3. Update all imports in EventDetailViewer.tsx
|
||||
4. Update kind registry in src/components/nostr/kinds/index.tsx
|
||||
5. Run build and tests to verify no breakage
|
||||
6. Update any documentation references
|
||||
|
||||
**Benefits**:
|
||||
- Self-documenting code - component name explains what it renders
|
||||
- Better developer experience for new contributors
|
||||
- Easier to find components by searching for semantic names
|
||||
- Aligns with common practices in React codebases
|
||||
|
||||
**Note**: Keep kind number comments in files to maintain traceability to Nostr specs
|
||||
|
||||
### Locale-Aware Date Formatting Audit
|
||||
**Priority**: Medium | **Effort**: Low
|
||||
**Files**: All component files that display dates/times
|
||||
|
||||
**Current State**:
|
||||
- `BaseEventRenderer.tsx` correctly implements locale-aware formatting using `formatTimestamp` from useLocale hook
|
||||
- `useGrimoire()` provides locale state from grimoire state atom
|
||||
- Pattern: `formatTimestamp(event.created_at, "relative", locale.locale)` for relative times
|
||||
- Pattern: `formatTimestamp(event.created_at, "absolute", locale.locale)` for full dates
|
||||
|
||||
**Audit Tasks**:
|
||||
1. Search codebase for all date/time formatting
|
||||
2. Identify any components using `new Date().toLocaleString()` without locale parameter
|
||||
3. Identify any hardcoded date formats
|
||||
4. Replace with formatTimestamp utility where applicable
|
||||
5. Verify all date displays respect user's locale setting
|
||||
6. Test with different locales (en-US, es-ES, ja-JP, ar-SA for RTL)
|
||||
|
||||
**Known Good Patterns**:
|
||||
- ✅ BaseEventRenderer - Uses formatTimestamp with locale
|
||||
- ✅ EventDetailViewer - No date display (delegates to renderers)
|
||||
- ✅ ProfileViewer - No date display currently
|
||||
|
||||
**Files to Check**:
|
||||
- All Kind*Renderer.tsx files
|
||||
- Timeline/feed components
|
||||
- Any custom date displays
|
||||
- Comment/reply timestamps
|
||||
- Event metadata displays
|
||||
|
||||
**Testing**:
|
||||
- Change locale in grimoire state
|
||||
- Verify all dates update to new locale format
|
||||
- Test relative times ("2m ago", "3h ago") in different languages
|
||||
- Test absolute times with various locale date formats
|
||||
|
||||
**Benefits**:
|
||||
- Consistent internationalization support
|
||||
- Better UX for non-English users
|
||||
- Follows best practices for locale-aware applications
|
||||
- Prepares codebase for full i18n implementation (see Phase 3.4)
|
||||
|
||||
### NIP-22 Comment Threading Support
|
||||
**Priority**: High
|
||||
**Files**: `src/components/nostr/kinds/Kind1Renderer.tsx`, potentially new `Kind1111Renderer.tsx`
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { KindRenderer } from "./nostr/kinds";
|
||||
import { Kind0DetailRenderer } from "./nostr/kinds/Kind0DetailRenderer";
|
||||
import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer";
|
||||
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
|
||||
import { Kind1621DetailRenderer } from "./nostr/kinds/Kind1621DetailRenderer";
|
||||
import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
|
||||
import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer";
|
||||
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
|
||||
import { Kind30617DetailRenderer } from "./nostr/kinds/Kind30617DetailRenderer";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import {
|
||||
@@ -33,6 +35,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { nip19, kinds } from "nostr-tools";
|
||||
import { useCopy } from "../hooks/useCopy";
|
||||
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import type { RelayState } from "@/types/relay-state";
|
||||
|
||||
@@ -138,7 +141,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
: nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: event.tags.find((t) => t[0] === "d")?.[1] || "",
|
||||
identifier: getTagValue(event, "d") || "",
|
||||
relays: relays,
|
||||
});
|
||||
|
||||
@@ -264,12 +267,16 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
<Kind0DetailRenderer event={event} />
|
||||
) : event.kind === kinds.Contacts ? (
|
||||
<Kind3DetailView event={event} />
|
||||
) : event.kind === kinds.LongFormArticle ? (
|
||||
<Kind30023DetailRenderer event={event} />
|
||||
) : event.kind === 1621 ? (
|
||||
<Kind1621DetailRenderer event={event} />
|
||||
) : event.kind === kinds.Highlights ? (
|
||||
<Kind9802DetailRenderer event={event} />
|
||||
) : event.kind === kinds.RelayList ? (
|
||||
<Kind10002DetailRenderer event={event} />
|
||||
) : event.kind === kinds.LongFormArticle ? (
|
||||
<Kind30023DetailRenderer event={event} />
|
||||
) : event.kind === 30617 ? (
|
||||
<Kind30617DetailRenderer event={event} />
|
||||
) : (
|
||||
<KindRenderer event={event} />
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCopy } from "@/hooks/useCopy";
|
||||
import { JsonViewer } from "@/components/JsonViewer";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { EventFooter } from "@/components/EventFooter";
|
||||
|
||||
// NIP-01 Kind ranges
|
||||
@@ -61,7 +62,7 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
let pointer;
|
||||
if (isAddressable) {
|
||||
// Find d-tag for identifier
|
||||
const dTag = event.tags.find((t) => t[0] === "d")?.[1] || "";
|
||||
const dTag = getTagValue(event, "d") || "";
|
||||
pointer = {
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
@@ -86,7 +87,7 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
|
||||
if (isAddressable) {
|
||||
// Find d-tag for identifier
|
||||
const dTag = event.tags.find((t) => t[0] === "d")?.[1] || "";
|
||||
const dTag = getTagValue(event, "d") || "";
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
|
||||
322
src/components/nostr/kinds/Kind1621DetailRenderer.tsx
Normal file
322
src/components/nostr/kinds/Kind1621DetailRenderer.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { remarkNostrMentions } from "applesauce-content/markdown";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Tag, GitBranch } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
getIssueTitle,
|
||||
getIssueLabels,
|
||||
getIssueRepositoryAddress,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Component to render nostr: mentions inline
|
||||
*/
|
||||
function NostrMention({ href }: { href: string }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
try {
|
||||
// Remove nostr: prefix and any trailing characters
|
||||
const cleanHref = href.replace(/^nostr:/, "").trim();
|
||||
|
||||
// If it doesn't look like a nostr identifier, just return the href as-is
|
||||
if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = nip19.decode(cleanHref);
|
||||
|
||||
switch (parsed.type) {
|
||||
case "npub":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "nprofile":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data.pubkey}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "note":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "nevent":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data.id}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "naddr":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
addressPointer={parsed.data}
|
||||
onOpen={(pointer) => {
|
||||
addWindow(
|
||||
"open",
|
||||
pointer,
|
||||
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span className="text-muted-foreground">{cleanHref}</span>;
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, just render as a regular link
|
||||
console.error("Failed to parse nostr link:", href, error);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1621 - Issue
|
||||
* Displays full issue content with markdown rendering
|
||||
*/
|
||||
export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const title = useMemo(() => getIssueTitle(event), [event]);
|
||||
const labels = useMemo(() => getIssueLabels(event), [event]);
|
||||
const repoAddress = useMemo(() => getIssueRepositoryAddress(event), [event]);
|
||||
|
||||
// Parse repository address
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!repoAddress) return null;
|
||||
try {
|
||||
const [kindStr, pubkey, identifier] = repoAddress.split(":");
|
||||
return {
|
||||
kind: parseInt(kindStr),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [repoAddress]);
|
||||
|
||||
// Fetch repository event
|
||||
const repoEvent = useNostrEvent(repoPointer || undefined);
|
||||
|
||||
// Get repository display name
|
||||
const repoName = repoEvent
|
||||
? getRepositoryName(repoEvent) ||
|
||||
getRepositoryIdentifier(repoEvent) ||
|
||||
"Repository"
|
||||
: repoPointer?.identifier || "Unknown Repository";
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer || !repoEvent) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* Issue Header */}
|
||||
<header className="flex flex-col gap-4 pb-4 border-b border-border">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">{title || "Untitled Issue"}</h1>
|
||||
|
||||
{/* Repository Link */}
|
||||
{repoAddress && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Repository:</span>
|
||||
<button
|
||||
onClick={repoEvent ? handleRepoClick : undefined}
|
||||
disabled={!repoEvent}
|
||||
className={`flex items-center gap-2 font-mono ${
|
||||
repoEvent
|
||||
? "text-muted-foreground underline decoration-dotted cursor-crosshair hover:text-primary"
|
||||
: "text-muted-foreground cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="size-4" />
|
||||
{repoName}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>By</span>
|
||||
<UserName pubkey={event.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
<span>•</span>
|
||||
<time>{createdDate}</time>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag className="size-3 text-muted-foreground" />
|
||||
{labels.map((label, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1 border border-muted border-dotted text-muted-foreground text-xs"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Issue Body - Markdown */}
|
||||
{event.content ? (
|
||||
<article className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkNostrMentions]}
|
||||
skipHtml
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith("nostr:")) return url;
|
||||
return defaultUrlTransform(url);
|
||||
}}
|
||||
components={{
|
||||
// Enable images with zoom
|
||||
img: ({ src, alt }) =>
|
||||
src ? (
|
||||
<MediaEmbed
|
||||
url={src}
|
||||
alt={alt}
|
||||
preset="preview"
|
||||
enableZoom
|
||||
className="my-4"
|
||||
/>
|
||||
) : null,
|
||||
// Handle nostr: links
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (!href) return null;
|
||||
|
||||
// Render nostr: mentions inline
|
||||
if (href.startsWith("nostr:")) {
|
||||
return <NostrMention href={href} />;
|
||||
}
|
||||
|
||||
// Regular links
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Style adjustments for dark theme
|
||||
h1: ({ ...props }) => (
|
||||
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="text-sm leading-relaxed mb-4" {...props} />
|
||||
),
|
||||
code: ({ ...props }: any) => (
|
||||
<code
|
||||
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="text-sm list-disc list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="text-sm list-decimal list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="my-4" />,
|
||||
}}
|
||||
>
|
||||
{event.content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
(No description provided)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/nostr/kinds/Kind1621Renderer.tsx
Normal file
110
src/components/nostr/kinds/Kind1621Renderer.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import {
|
||||
getIssueTitle,
|
||||
getIssueLabels,
|
||||
getIssueRepositoryAddress,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { UserName } from "../UserName";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1621 - Issue
|
||||
* Displays as a compact issue card in feed view
|
||||
*/
|
||||
export function Kind1621Renderer({ event }: BaseEventProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const title = getIssueTitle(event);
|
||||
const labels = getIssueLabels(event);
|
||||
const repoAddress = getIssueRepositoryAddress(event);
|
||||
|
||||
// Parse repository address to get the pointer
|
||||
const repoPointer = repoAddress
|
||||
? (() => {
|
||||
try {
|
||||
// Address format: "kind:pubkey:identifier"
|
||||
const [kindStr, pubkey, identifier] = repoAddress.split(":");
|
||||
return {
|
||||
kind: parseInt(kindStr),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Fetch the repository event to get its name
|
||||
const repoEvent = useNostrEvent(
|
||||
repoPointer
|
||||
? {
|
||||
kind: repoPointer.kind,
|
||||
pubkey: repoPointer.pubkey,
|
||||
identifier: repoPointer.identifier,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Get repository display name
|
||||
const repoName = repoEvent
|
||||
? getRepositoryName(repoEvent) ||
|
||||
getRepositoryIdentifier(repoEvent) ||
|
||||
"Repository"
|
||||
: repoAddress?.split(":")[2] || "Unknown Repository";
|
||||
|
||||
const handleRepoClick = () => {
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Issue Title */}
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{title || "Untitled Issue"}
|
||||
</h3>
|
||||
|
||||
{/* Repository Reference */}
|
||||
{repoAddress && repoPointer && (
|
||||
<div className="flex items-center gap-1 text-xs line-clamp-1">
|
||||
<span>in </span>
|
||||
<div
|
||||
onClick={handleRepoClick}
|
||||
className={`flex items-center gap-1 text-muted-foreground
|
||||
cursor-crosshair underline decoration-dotted hover:text-primary
|
||||
`}
|
||||
>
|
||||
<GitBranch className="size-3" />
|
||||
<span>{repoName}</span>
|
||||
</div>
|
||||
<span>by</span>
|
||||
<UserName pubkey={repoPointer.pubkey} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div
|
||||
className="flex
|
||||
items-center gap-1 overflow-x-scroll my-1"
|
||||
>
|
||||
{labels.map((label, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 border border-muted border-dotted text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
192
src/components/nostr/kinds/Kind30617DetailRenderer.tsx
Normal file
192
src/components/nostr/kinds/Kind30617DetailRenderer.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useMemo } from "react";
|
||||
import { Globe, Copy, Users, CopyCheck, Server } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { RelayLink } from "../RelayLink";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryDescription,
|
||||
getRepositoryIdentifier,
|
||||
getCloneUrls,
|
||||
getWebUrls,
|
||||
getMaintainers,
|
||||
getRepositoryRelays,
|
||||
} from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 30617 - Repository
|
||||
* Displays full repository metadata with all URLs and maintainers
|
||||
*/
|
||||
export function Kind30617DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const name = useMemo(() => getRepositoryName(event), [event]);
|
||||
const description = useMemo(() => getRepositoryDescription(event), [event]);
|
||||
const identifier = useMemo(() => getRepositoryIdentifier(event), [event]);
|
||||
const webUrls = useMemo(() => getWebUrls(event), [event]);
|
||||
const cloneUrls = useMemo(() => getCloneUrls(event), [event]);
|
||||
const maintainers = useMemo(() => getMaintainers(event), [event]);
|
||||
const relays = useMemo(() => getRepositoryRelays(event), [event]);
|
||||
|
||||
const displayName = name || identifier || "Repository";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* Repository Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-4">
|
||||
{/* Name */}
|
||||
<h1 className="text-3xl font-bold">{displayName}</h1>
|
||||
|
||||
{/* Identifier */}
|
||||
{identifier && (
|
||||
<code className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1 w-fit">
|
||||
<UserName pubkey={event.pubkey} />/{identifier}
|
||||
</code>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* URLs Section */}
|
||||
{(webUrls.length > 0 || cloneUrls.length > 0) && (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Globe className="size-5" />
|
||||
URLs
|
||||
</h2>
|
||||
|
||||
{/* Web URLs */}
|
||||
{webUrls.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Website
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{webUrls.map((url, idx) => (
|
||||
<UrlItem key={idx} url={url} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clone URLs */}
|
||||
{cloneUrls.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
git URLs
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{cloneUrls.map((url, idx) => (
|
||||
<CloneUrlItem key={idx} url={url} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Maintainers Section */}
|
||||
{maintainers.length > 0 && (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Users className="size-5" />
|
||||
Maintainers
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{maintainers.map((pubkey) => (
|
||||
<UserName
|
||||
key={pubkey}
|
||||
pubkey={pubkey}
|
||||
className="font-semibold"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Relay Hints Section */}
|
||||
{relays.length > 0 && (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Server className="size-5" />
|
||||
Relays
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{relays.map((url) => (
|
||||
<li key={url} className="flex items-center gap-2">
|
||||
<RelayLink
|
||||
url={url}
|
||||
showInboxOutbox={false}
|
||||
className="hover:bg-background hover:underline hover:decoration-dotted"
|
||||
urlClassname="text-sm"
|
||||
iconClassname="size-3"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a web URL with copy button
|
||||
*/
|
||||
function UrlItem({ url }: { url: string }) {
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 p-2 bg-muted/30 group">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-sm text-muted-foreground hover:underline hover:decoration-dotted cursor-crosshair line-clamp-1 break-all"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => copy(url)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy URL"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a clone URL with copy button
|
||||
*/
|
||||
function CloneUrlItem({ url }: { url: string }) {
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 p-2 bg-muted/30 font-mono group">
|
||||
<code className="flex-1 text-sm text-muted-foreground break-all line-clamp-1">
|
||||
{url}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(url)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy clone URL"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
65
src/components/nostr/kinds/Kind30617Renderer.tsx
Normal file
65
src/components/nostr/kinds/Kind30617Renderer.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { GitBranch, Globe } from "lucide-react";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryDescription,
|
||||
getWebUrls,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { getReplaceableIdentifier } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 30617 - Repository
|
||||
* Displays as a compact repository card in feed view
|
||||
*/
|
||||
export function Kind30617Renderer({ event }: BaseEventProps) {
|
||||
const name = getRepositoryName(event);
|
||||
const description = getRepositoryDescription(event);
|
||||
const identifier = getReplaceableIdentifier(event);
|
||||
const webUrls = getWebUrls(event);
|
||||
|
||||
// Use name if available, otherwise use identifier, fallback to "Repository"
|
||||
const displayName = name || identifier || "Repository";
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Repository Info */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Name and Owner */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* URLs and Maintainers */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
{/* First Web URL */}
|
||||
{webUrls.length > 0 && (
|
||||
<a
|
||||
href={webUrls[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-muted-foreground underline decoration-dotted cursor-crosshair line-clamp-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Globe className="size-3" />
|
||||
<span className="truncate line-clamp-1">{webUrls[0]}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import { Kind20Renderer } from "./Kind20Renderer";
|
||||
import { Kind21Renderer } from "./Kind21Renderer";
|
||||
import { Kind22Renderer } from "./Kind22Renderer";
|
||||
import { Kind1063Renderer } from "./Kind1063Renderer";
|
||||
import { Kind1621Renderer } from "./Kind1621Renderer";
|
||||
import { Kind9735Renderer } from "./Kind9735Renderer";
|
||||
import { Kind9802Renderer } from "./Kind9802Renderer";
|
||||
import { Kind10002Renderer } from "./Kind10002Renderer";
|
||||
import { Kind30023Renderer } from "./Kind30023Renderer";
|
||||
import { Kind30617Renderer } from "./Kind30617Renderer";
|
||||
import { Kind39701Renderer } from "./Kind39701Renderer";
|
||||
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
@@ -34,6 +36,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
22: Kind22Renderer, // Short Video (NIP-71)
|
||||
1063: Kind1063Renderer, // File Metadata (NIP-94)
|
||||
1111: Kind1Renderer, // Post
|
||||
1621: Kind1621Renderer, // Issue (NIP-34)
|
||||
9735: Kind9735Renderer, // Zap Receipt
|
||||
9802: Kind9802Renderer, // Highlight
|
||||
10002: Kind10002Renderer, // Relay List Metadata (NIP-65)
|
||||
@@ -43,6 +46,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
|
||||
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
|
||||
30023: Kind30023Renderer, // Long-form Article
|
||||
30617: Kind30617Renderer, // Repository (NIP-34)
|
||||
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
|
||||
};
|
||||
|
||||
|
||||
@@ -489,29 +489,29 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
},
|
||||
1630: {
|
||||
kind: 1630,
|
||||
name: "Status",
|
||||
description: "Status",
|
||||
name: "Open Status",
|
||||
description: "Open",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
},
|
||||
1631: {
|
||||
kind: 1631,
|
||||
name: "Status",
|
||||
description: "Status",
|
||||
name: "Applied/Merged",
|
||||
description: "Applied / Merged for Patches; Resolved for Issues",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
},
|
||||
1632: {
|
||||
kind: 1632,
|
||||
name: "Status",
|
||||
description: "Status",
|
||||
name: "Closed Status",
|
||||
description: "Closed",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
},
|
||||
1633: {
|
||||
kind: 1633,
|
||||
name: "Status",
|
||||
description: "Status",
|
||||
name: "Draft Status",
|
||||
description: "Draft",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
},
|
||||
@@ -1216,7 +1216,7 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
},
|
||||
30617: {
|
||||
kind: 30617,
|
||||
name: "Repo Announce",
|
||||
name: "Repository",
|
||||
description: "Repository announcements",
|
||||
nip: "34",
|
||||
icon: GitBranch,
|
||||
|
||||
124
src/lib/nip34-helpers.ts
Normal file
124
src/lib/nip34-helpers.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* NIP-34 Helper Functions
|
||||
* Utility functions for parsing NIP-34 git event tags
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Repository Event Helpers (Kind 30617)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the repository name from a repository event
|
||||
* @param event Repository event (kind 30617)
|
||||
* @returns Repository name or undefined
|
||||
*/
|
||||
export function getRepositoryName(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "name");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository description
|
||||
* @param event Repository event (kind 30617)
|
||||
* @returns Repository description or undefined
|
||||
*/
|
||||
export function getRepositoryDescription(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "description");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository identifier (d tag)
|
||||
* @param event Repository event (kind 30617)
|
||||
* @returns Repository identifier or undefined
|
||||
*/
|
||||
export function getRepositoryIdentifier(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "d");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clone URLs from a repository event
|
||||
* @param event Repository event (kind 30617)
|
||||
* @returns Array of clone URLs
|
||||
*/
|
||||
export function getCloneUrls(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all web URLs from a repository event
|
||||
* @param event Repository event (kind 30617)
|
||||
* @returns Array of web URLs
|
||||
*/
|
||||
export function getWebUrls(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "web").map((t) => t[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all maintainer pubkeys from a repository event
|
||||
* @param event Repository event (kind 30617)
|
||||
* @returns Array of maintainer pubkeys
|
||||
*/
|
||||
export function getMaintainers(event: NostrEvent): string[] {
|
||||
return event.tags
|
||||
.filter((t) => t[0] === "maintainers")
|
||||
.map((t) => t[1])
|
||||
.filter((p: string) => p !== event.pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relay hints for patches and issues
|
||||
* @param event Repository event (kind 30617)
|
||||
* @returns Array of relay URLs
|
||||
*/
|
||||
export function getRepositoryRelays(event: NostrEvent): string[] {
|
||||
const relaysTag = event.tags.find((t) => t[0] === "relays");
|
||||
if (!relaysTag) return [];
|
||||
const [, ...relays] = relaysTag;
|
||||
return relays;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Issue Event Helpers (Kind 1621)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the issue title/subject
|
||||
* @param event Issue event (kind 1621)
|
||||
* @returns Issue title or undefined
|
||||
*/
|
||||
export function getIssueTitle(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "subject");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all issue labels/tags
|
||||
* @param event Issue event (kind 1621)
|
||||
* @returns Array of label strings
|
||||
*/
|
||||
export function getIssueLabels(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository address pointer for an issue
|
||||
* @param event Issue event (kind 1621)
|
||||
* @returns Repository address pointer (a tag) or undefined
|
||||
*/
|
||||
export function getIssueRepositoryAddress(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "a");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository owner pubkey for an issue
|
||||
* @param event Issue event (kind 1621)
|
||||
* @returns Repository owner pubkey or undefined
|
||||
*/
|
||||
export function getIssueRepositoryOwner(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "p");
|
||||
}
|
||||
Reference in New Issue
Block a user