mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
Add NIP-85 Trusted Assertions support with renderers (#252)
* feat: add NIP-85 Trusted Assertions feed & detail renderers Add support for NIP-85 Trusted Assertion events (kinds 30382-30385) and the Trusted Provider Declaration (kind 10040) with kind constants, helper library, and feed + detail renderers. - Add kind entries for 10040, 30382, 30383, 30384, 30385 to EVENT_KINDS - Create src/lib/nip85-helpers.ts with cached helpers for parsing assertion data (user, event, address, external) and provider lists - Create shared TrustedAssertionRenderer for all 4 assertion kinds with rank bar, subject display, and compact metrics preview - Create TrustedAssertionDetailRenderer with full metrics table, rank visualization, topics, and raw tag fallback - Create TrustedProviderListRenderer/DetailRenderer for kind 10040 with provider table and encrypted entries indicator - Register all renderers in kinds/index.tsx https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * refactor: extract NIP-73 helpers and shared ExternalIdentifierDisplay Move getExternalIdentifierIcon() and getExternalIdentifierLabel() from nip22-helpers.ts into a new nip73-helpers.ts since they are NIP-73 utilities, not NIP-22 specific. Add inferExternalIdentifierType() and getExternalIdentifierHref() helpers. Create shared ExternalIdentifierDisplay components (inline + block variants) that use proper NIP-73 type-specific icons (Globe for web, BookOpen for ISBN, Podcast for podcasts, Film for ISAN, etc.) instead of a generic ExternalLink icon. - Kind 1111 renderer now uses ExternalIdentifierInline for root scope - Kind 30385 assertion renderer uses ExternalIdentifierInline (feed) and ExternalIdentifierBlock (detail) for NIP-73 subjects - nip22-helpers.ts re-exports from nip73-helpers for compatibility https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * fix: UI polish for NIP-85 renderers Address review feedback across all NIP-85 components: Icon: Replace BarChart3 with ShieldCheck for assertion kind constants (30382-30385) — communicates "verified trust data" vs generic analytics. Feed renderer: Subject is now the visual anchor (ClickableEventTitle) with kind label as a small outline Badge above it. Rank bar widened to w-32, color-coded green/yellow/red by score threshold. Fix "Zaps Recd" abbreviation to "Zaps In". Detail renderer: Metrics grouped into Activity, Zaps, Moderation sections with uppercase section headers. Kind 30384 addresses now show "Kind X by <UserName> / d-tag" instead of raw hex. ExternalMetrics type badges now show NIP-73 icons + friendly labels (getExternalTypeLabel) instead of raw k-tag values like "podcast:item:guid". Provider list: Feed uses compact Badge for kind:tag + count summary. Detail uses stacked cards instead of 3-column table for narrow panels. ExternalIdentifierBlock: Linked blocks now show dotted underline on label + ExternalLink icon for clear click affordance. Add getExternalTypeLabel() to nip73-helpers for k-value display names. https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * fix: use Progress component for rank bars, consistent sats formatting Replace custom rank bar divs with the existing Progress UI component (with new indicatorClassName prop for color-coded fills). Make sat amount displays consistent with zap receipt renderer pattern: value and "sats" unit are rendered as separate elements — numeric value in font-medium, unit in smaller muted text — matching how Kind9735 displays amounts across the app. https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * fix: use RelayLink for relay URLs, remove redundant kindTag, add relay hints Provider list renderers now use RelayLink instead of raw relay URL strings — shows favicon, insecure ws:// warning, opens relay detail on click. Remove kindTag display from provider cards — it's an internal protocol detail redundant in the UI context. Pass relay hints from provider entries to UserName so profiles can be fetched from the relay the provider actually publishes to. Add UserName relayHints prop (forwarded to useProfile). Add RelayLink and UserName to Shared Components section in CLAUDE.md so they're consistently used across the codebase. https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * docs: expand shared components section in CLAUDE.md Add BaseEventContainer, ClickableEventTitle, RichText, QuotedEvent, and CustomEmoji to the shared components reference. These are the core building blocks used across all kind renderers — documenting them prevents re-implementation and ensures consistent patterns. https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * docs: trim shared components list in CLAUDE.md Remove BaseEventContainer and QuotedEvent — these are internal patterns that kind renderer authors already know from context, not general-purpose components that get misused or forgotten. https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * feat: show metric type labels in trusted assertion feed view Add Label components to the assertion feed renderer so you can see at a glance which metrics an assertion carries (Followers, Posts, Zaps, etc.) instead of just numeric values. Also swap Badge → Label for the kind indicator for visual consistency. Replace hardcoded green/yellow/red rank colors with theme variables (success/warning/destructive) in both feed and detail renderers so the rank bar works correctly across all themes. Add Label to CLAUDE.md shared components list (22 imports across the codebase). https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * feat: show provider kind tag in trusted provider list renderers Add Label with formatKindTag() to both feed and detail views so each provider row shows what it provides (e.g. "User Assertion: Rank"). Also swap Badge → Label for consistency with the assertion renderers. https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB * fix: stabilize relayHints in useProfile to prevent fetch abort loop relayHints was used directly in the useEffect dependency array, so callers passing a new array literal (e.g. [p.relay]) on every render caused the effect to re-run each cycle — aborting the previous network fetch before it could complete. The IndexedDB fast-path masked this in the feed view (profiles already cached), but the detail view showed raw pubkeys because profiles were never fetched from the network. Wrap relayHints in a JSON.stringify-based useMemo (same pattern as useStableArray) so the effect only re-runs when the actual relay values change. https://claude.ai/code/session_01XjwLaShFSVPR5gNA7iUjuB --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
124
src/components/nostr/ExternalIdentifierDisplay.tsx
Normal file
124
src/components/nostr/ExternalIdentifierDisplay.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Shared UI components for displaying NIP-73 external content identifiers.
|
||||
*
|
||||
* Used by:
|
||||
* - Kind 1111 (NIP-22 Comment) — root scope display
|
||||
* - Kind 30385 (NIP-85 Trusted Assertion) — external subject display
|
||||
*/
|
||||
|
||||
import {
|
||||
getExternalIdentifierIcon,
|
||||
getExternalIdentifierLabel,
|
||||
getExternalIdentifierHref,
|
||||
inferExternalIdentifierType,
|
||||
} from "@/lib/nip73-helpers";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Inline external identifier — icon + label, optionally linked.
|
||||
* Compact version for feed renderers.
|
||||
*/
|
||||
export function ExternalIdentifierInline({
|
||||
value,
|
||||
kType,
|
||||
hint,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
kType?: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const type = kType || inferExternalIdentifierType(value);
|
||||
const Icon = getExternalIdentifierIcon(type);
|
||||
const label = getExternalIdentifierLabel(value, type);
|
||||
const href = getExternalIdentifierHref(value, hint);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Icon className="size-3 flex-shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const base = cn(
|
||||
"flex items-center gap-1.5 text-xs overflow-hidden min-w-0",
|
||||
className,
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
base,
|
||||
"text-muted-foreground underline decoration-dotted hover:text-foreground transition-colors",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className={cn(base, "text-muted-foreground")}>{content}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block-level external identifier display — icon + label in a card-like container.
|
||||
* Used in detail renderers.
|
||||
*/
|
||||
export function ExternalIdentifierBlock({
|
||||
value,
|
||||
kType,
|
||||
hint,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
kType?: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const type = kType || inferExternalIdentifierType(value);
|
||||
const Icon = getExternalIdentifierIcon(type);
|
||||
const label = getExternalIdentifierLabel(value, type);
|
||||
const href = getExternalIdentifierHref(value, hint);
|
||||
|
||||
const isLink = !!href;
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-md bg-muted/50",
|
||||
isLink && "group",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm break-all flex-1",
|
||||
isLink &&
|
||||
"underline decoration-dotted group-hover:text-foreground transition-colors",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{isLink && (
|
||||
<ExternalLink className="size-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLink) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface UserNameProps {
|
||||
pubkey: string;
|
||||
isMention?: boolean;
|
||||
className?: string;
|
||||
relayHints?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,9 +26,14 @@ interface UserNameProps {
|
||||
* - Premium supporters (2.1k+ sats/month): Flame badge in their username color
|
||||
* - Regular supporters: Yellow flame badge (no username color change)
|
||||
*/
|
||||
export function UserName({ pubkey, isMention, className }: UserNameProps) {
|
||||
export function UserName({
|
||||
pubkey,
|
||||
isMention,
|
||||
className,
|
||||
relayHints,
|
||||
}: UserNameProps) {
|
||||
const { addWindow, state } = useGrimoire();
|
||||
const profile = useProfile(pubkey);
|
||||
const profile = useProfile(pubkey, relayHints);
|
||||
const isGrimoire = isGrimoireMember(pubkey);
|
||||
const { isSupporter, isPremiumSupporter } = useIsSupporter(pubkey);
|
||||
const displayName = getDisplayName(pubkey, profile);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "applesauce-common/helpers/comment";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { UserName } from "../UserName";
|
||||
import { ExternalLink, Reply } from "lucide-react";
|
||||
import { Reply } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { InlineReplySkeleton } from "@/components/ui/skeleton";
|
||||
import { KindBadge } from "@/components/KindBadge";
|
||||
@@ -18,10 +18,10 @@ import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
getCommentRootScope,
|
||||
isTopLevelComment,
|
||||
getExternalIdentifierLabel,
|
||||
type CommentRootScope,
|
||||
type CommentScope,
|
||||
} from "@/lib/nip22-helpers";
|
||||
import { ExternalIdentifierInline } from "../ExternalIdentifierDisplay";
|
||||
|
||||
/**
|
||||
* Convert CommentPointer to pointer format for useNostrEvent
|
||||
@@ -149,21 +149,14 @@ function RootScopeDisplay({
|
||||
const pointer = scopeToPointer(root.scope);
|
||||
const rootEvent = useNostrEvent(pointer, event);
|
||||
|
||||
// External identifier (I-tag) — render as a link
|
||||
// External identifier (I-tag) — render using shared NIP-73 component
|
||||
if (root.scope.type === "external") {
|
||||
const { value, hint } = root.scope;
|
||||
const label = getExternalIdentifierLabel(value, root.kind);
|
||||
// Use hint if available, otherwise use value directly when it's a URL
|
||||
const href =
|
||||
hint ||
|
||||
(value.startsWith("http://") || value.startsWith("https://")
|
||||
? value
|
||||
: undefined);
|
||||
return (
|
||||
<ScopeRow href={href}>
|
||||
<ExternalLink className="size-3 flex-shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</ScopeRow>
|
||||
<ExternalIdentifierInline
|
||||
value={root.scope.value}
|
||||
kType={root.kind}
|
||||
hint={root.scope.hint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
472
src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
Normal file
472
src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { UserName } from "../UserName";
|
||||
import { ExternalIdentifierBlock } from "../ExternalIdentifierDisplay";
|
||||
import {
|
||||
getAssertionSubject,
|
||||
getAssertionTags,
|
||||
getUserAssertionData,
|
||||
getEventAssertionData,
|
||||
getExternalAssertionData,
|
||||
getExternalAssertionTypes,
|
||||
ASSERTION_KIND_LABELS,
|
||||
ASSERTION_TAG_LABELS,
|
||||
} from "@/lib/nip85-helpers";
|
||||
import {
|
||||
getExternalIdentifierIcon,
|
||||
getExternalTypeLabel,
|
||||
} from "@/lib/nip73-helpers";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { ShieldCheck, User, FileText, Hash } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function rankColor(rank: number) {
|
||||
if (rank >= 70) return { indicator: "bg-success", text: "text-success" };
|
||||
if (rank >= 40) return { indicator: "bg-warning", text: "text-warning" };
|
||||
return { indicator: "bg-destructive", text: "text-destructive" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Color-coded rank bar with label, using Progress component
|
||||
*/
|
||||
function RankBar({ rank }: { rank: number }) {
|
||||
const clamped = Math.min(100, Math.max(0, rank));
|
||||
const { indicator, text } = rankColor(clamped);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">Rank</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Progress
|
||||
value={clamped}
|
||||
className="flex-1 bg-muted"
|
||||
indicatorClassName={indicator}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold tabular-nums w-12 text-right",
|
||||
text,
|
||||
)}
|
||||
>
|
||||
{rank}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric row for detail table
|
||||
*/
|
||||
function MetricRow({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-1.5 border-b border-border/30 last:border-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{value}
|
||||
{unit && (
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header for metric groups
|
||||
*/
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground pt-2 first:pt-0">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject header — clickable for Nostr subjects, rich display for external
|
||||
*/
|
||||
function SubjectHeader({
|
||||
event,
|
||||
subject,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
subject: string;
|
||||
}) {
|
||||
// Kind 30385: NIP-73 external identifier
|
||||
if (event.kind === 30385) {
|
||||
const kTypes = getExternalAssertionTypes(event);
|
||||
return <ExternalIdentifierBlock value={subject} kType={kTypes[0]} />;
|
||||
}
|
||||
|
||||
// Kind 30382: user pubkey
|
||||
if (event.kind === 30382) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
|
||||
<User className="size-4 text-muted-foreground" />
|
||||
<UserName pubkey={subject} className="font-medium" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Kind 30384: addressable event (kind:pubkey:d-tag)
|
||||
if (event.kind === 30384) {
|
||||
const parts = subject.split(":");
|
||||
if (parts.length >= 3) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
<span className="font-mono text-sm">
|
||||
Kind {parts[0]} by{" "}
|
||||
<UserName pubkey={parts[1]} className="text-sm inline" />
|
||||
{parts[2] && (
|
||||
<span className="text-muted-foreground"> / {parts[2]}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Kind 30383: event ID
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
<span className="font-mono text-sm break-all">{subject}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User assertion metrics (kind 30382) — grouped into sections
|
||||
*/
|
||||
function UserMetrics({ event }: { event: NostrEvent }) {
|
||||
const data = getUserAssertionData(event);
|
||||
|
||||
type Metric = { label: string; value: string | number; unit?: string };
|
||||
|
||||
// Activity section
|
||||
const activity: Metric[] = [];
|
||||
if (data.postCount !== undefined)
|
||||
activity.push({ label: "Posts", value: data.postCount.toLocaleString() });
|
||||
if (data.replyCount !== undefined)
|
||||
activity.push({
|
||||
label: "Replies",
|
||||
value: data.replyCount.toLocaleString(),
|
||||
});
|
||||
if (data.reactionsCount !== undefined)
|
||||
activity.push({
|
||||
label: "Reactions",
|
||||
value: data.reactionsCount.toLocaleString(),
|
||||
});
|
||||
if (data.followers !== undefined)
|
||||
activity.push({
|
||||
label: "Followers",
|
||||
value: data.followers.toLocaleString(),
|
||||
});
|
||||
if (data.firstCreatedAt !== undefined)
|
||||
activity.push({
|
||||
label: "First Post",
|
||||
value: formatTimestamp(data.firstCreatedAt, "long"),
|
||||
});
|
||||
if (data.activeHoursStart !== undefined && data.activeHoursEnd !== undefined)
|
||||
activity.push({
|
||||
label: "Active Hours (UTC)",
|
||||
value: `${data.activeHoursStart}:00 – ${data.activeHoursEnd}:00`,
|
||||
});
|
||||
|
||||
// Zaps section
|
||||
const zaps: Metric[] = [];
|
||||
if (data.zapAmountReceived !== undefined)
|
||||
zaps.push({
|
||||
label: "Received",
|
||||
value: data.zapAmountReceived.toLocaleString(),
|
||||
unit: "sats",
|
||||
});
|
||||
if (data.zapAmountSent !== undefined)
|
||||
zaps.push({
|
||||
label: "Sent",
|
||||
value: data.zapAmountSent.toLocaleString(),
|
||||
unit: "sats",
|
||||
});
|
||||
if (data.zapCountReceived !== undefined)
|
||||
zaps.push({
|
||||
label: "Count In",
|
||||
value: data.zapCountReceived.toLocaleString(),
|
||||
});
|
||||
if (data.zapCountSent !== undefined)
|
||||
zaps.push({
|
||||
label: "Count Out",
|
||||
value: data.zapCountSent.toLocaleString(),
|
||||
});
|
||||
if (data.zapAvgAmountDayReceived !== undefined)
|
||||
zaps.push({
|
||||
label: "Avg/Day In",
|
||||
value: data.zapAvgAmountDayReceived.toLocaleString(),
|
||||
unit: "sats",
|
||||
});
|
||||
if (data.zapAvgAmountDaySent !== undefined)
|
||||
zaps.push({
|
||||
label: "Avg/Day Out",
|
||||
value: data.zapAvgAmountDaySent.toLocaleString(),
|
||||
unit: "sats",
|
||||
});
|
||||
|
||||
// Moderation section
|
||||
const moderation: Metric[] = [];
|
||||
if (data.reportsReceived !== undefined)
|
||||
moderation.push({
|
||||
label: "Reports Received",
|
||||
value: data.reportsReceived.toLocaleString(),
|
||||
});
|
||||
if (data.reportsSent !== undefined)
|
||||
moderation.push({
|
||||
label: "Reports Sent",
|
||||
value: data.reportsSent.toLocaleString(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activity.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader>Activity</SectionHeader>
|
||||
{activity.map((m) => (
|
||||
<MetricRow
|
||||
key={m.label}
|
||||
label={m.label}
|
||||
value={m.value}
|
||||
unit={m.unit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zaps.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader>Zaps</SectionHeader>
|
||||
{zaps.map((m) => (
|
||||
<MetricRow
|
||||
key={m.label}
|
||||
label={m.label}
|
||||
value={m.value}
|
||||
unit={m.unit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moderation.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader>Moderation</SectionHeader>
|
||||
{moderation.map((m) => (
|
||||
<MetricRow
|
||||
key={m.label}
|
||||
label={m.label}
|
||||
value={m.value}
|
||||
unit={m.unit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.topics && data.topics.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SectionHeader>Topics</SectionHeader>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.topics.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
<Hash className="size-3" />
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event/address assertion metrics (kind 30383/30384)
|
||||
*/
|
||||
function EventMetrics({ event }: { event: NostrEvent }) {
|
||||
const data = getEventAssertionData(event);
|
||||
|
||||
const metrics: { label: string; value: string | number; unit?: string }[] =
|
||||
[];
|
||||
if (data.commentCount !== undefined)
|
||||
metrics.push({
|
||||
label: "Comments",
|
||||
value: data.commentCount.toLocaleString(),
|
||||
});
|
||||
if (data.quoteCount !== undefined)
|
||||
metrics.push({ label: "Quotes", value: data.quoteCount.toLocaleString() });
|
||||
if (data.repostCount !== undefined)
|
||||
metrics.push({
|
||||
label: "Reposts",
|
||||
value: data.repostCount.toLocaleString(),
|
||||
});
|
||||
if (data.reactionCount !== undefined)
|
||||
metrics.push({
|
||||
label: "Reactions",
|
||||
value: data.reactionCount.toLocaleString(),
|
||||
});
|
||||
if (data.zapCount !== undefined)
|
||||
metrics.push({ label: "Zap Count", value: data.zapCount.toLocaleString() });
|
||||
if (data.zapAmount !== undefined)
|
||||
metrics.push({
|
||||
label: "Zap Amount",
|
||||
value: data.zapAmount.toLocaleString(),
|
||||
unit: "sats",
|
||||
});
|
||||
|
||||
if (metrics.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader>Engagement</SectionHeader>
|
||||
{metrics.map((m) => (
|
||||
<MetricRow key={m.label} label={m.label} value={m.value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* External assertion metrics (kind 30385) — with friendly type labels + icons
|
||||
*/
|
||||
function ExternalMetrics({ event }: { event: NostrEvent }) {
|
||||
const data = getExternalAssertionData(event);
|
||||
const types = getExternalAssertionTypes(event);
|
||||
|
||||
const metrics: { label: string; value: string | number; unit?: string }[] =
|
||||
[];
|
||||
if (data.commentCount !== undefined)
|
||||
metrics.push({
|
||||
label: "Comments",
|
||||
value: data.commentCount.toLocaleString(),
|
||||
});
|
||||
if (data.reactionCount !== undefined)
|
||||
metrics.push({
|
||||
label: "Reactions",
|
||||
value: data.reactionCount.toLocaleString(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{types.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SectionHeader>Content Type</SectionHeader>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{types.map((t) => {
|
||||
const Icon = getExternalIdentifierIcon(t);
|
||||
return (
|
||||
<span
|
||||
key={t}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium"
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{getExternalTypeLabel(t)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metrics.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader>Engagement</SectionHeader>
|
||||
{metrics.map((m) => (
|
||||
<MetricRow
|
||||
key={m.label}
|
||||
label={m.label}
|
||||
value={m.value}
|
||||
unit={m.unit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: show any unrecognized tags as raw rows
|
||||
*/
|
||||
function RawAssertionTags({ event }: { event: NostrEvent }) {
|
||||
const tags = getAssertionTags(event);
|
||||
const knownTags = new Set(Object.keys(ASSERTION_TAG_LABELS));
|
||||
const unknownTags = tags.filter(
|
||||
(t) => !knownTags.has(t.name) && t.name !== "t",
|
||||
);
|
||||
|
||||
if (unknownTags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader>Other</SectionHeader>
|
||||
{unknownTags.map((t, i) => (
|
||||
<MetricRow key={`${t.name}-${i}`} label={t.name} value={t.value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted Assertion Detail Renderer (Kinds 30382-30385)
|
||||
*/
|
||||
export function TrustedAssertionDetailRenderer({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const subject = getAssertionSubject(event);
|
||||
const kindLabel = ASSERTION_KIND_LABELS[event.kind] || "Assertion";
|
||||
const tags = getAssertionTags(event);
|
||||
const rankTag = tags.find((t) => t.name === "rank");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-6 max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">{kindLabel}</h2>
|
||||
</div>
|
||||
|
||||
{/* Provider */}
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="text-muted-foreground">Provider:</span>
|
||||
<UserName pubkey={event.pubkey} className="font-medium" />
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
{subject && <SubjectHeader event={event} subject={subject} />}
|
||||
|
||||
{/* Rank */}
|
||||
{rankTag && <RankBar rank={parseInt(rankTag.value, 10)} />}
|
||||
|
||||
{/* Kind-specific metrics */}
|
||||
{event.kind === 30382 && <UserMetrics event={event} />}
|
||||
{(event.kind === 30383 || event.kind === 30384) && (
|
||||
<EventMetrics event={event} />
|
||||
)}
|
||||
{event.kind === 30385 && <ExternalMetrics event={event} />}
|
||||
|
||||
{/* Raw/unknown tags */}
|
||||
<RawAssertionTags event={event} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
src/components/nostr/kinds/TrustedAssertionRenderer.tsx
Normal file
248
src/components/nostr/kinds/TrustedAssertionRenderer.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
BaseEventProps,
|
||||
BaseEventContainer,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UserName } from "../UserName";
|
||||
import { ExternalIdentifierInline } from "../ExternalIdentifierDisplay";
|
||||
import {
|
||||
getAssertionSubject,
|
||||
getAssertionTags,
|
||||
getUserAssertionData,
|
||||
getEventAssertionData,
|
||||
getExternalAssertionData,
|
||||
getExternalAssertionTypes,
|
||||
ASSERTION_KIND_LABELS,
|
||||
ASSERTION_TAG_LABELS,
|
||||
} from "@/lib/nip85-helpers";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function rankColor(rank: number) {
|
||||
if (rank >= 70) return { indicator: "bg-success", text: "text-success" };
|
||||
if (rank >= 40) return { indicator: "bg-warning", text: "text-warning" };
|
||||
return { indicator: "bg-destructive", text: "text-destructive" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Color-coded rank bar using Progress component
|
||||
*/
|
||||
function RankBar({ rank }: { rank: number }) {
|
||||
const clamped = Math.min(100, Math.max(0, rank));
|
||||
const { indicator, text } = rankColor(clamped);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Progress
|
||||
value={clamped}
|
||||
className="w-32 bg-muted"
|
||||
indicatorClassName={indicator}
|
||||
/>
|
||||
<span className={cn("text-xs font-semibold tabular-nums", text)}>
|
||||
{rank}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject as the visual anchor — rendered as ClickableEventTitle
|
||||
*/
|
||||
function SubjectTitle({
|
||||
event,
|
||||
subject,
|
||||
}: {
|
||||
event: BaseEventProps["event"];
|
||||
subject: string;
|
||||
}) {
|
||||
if (event.kind === 30382) {
|
||||
return (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
<UserName pubkey={subject} className="text-base font-semibold" />
|
||||
</ClickableEventTitle>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.kind === 30385) {
|
||||
const kTypes = getExternalAssertionTypes(event);
|
||||
return (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
<ExternalIdentifierInline
|
||||
value={subject}
|
||||
kType={kTypes[0]}
|
||||
className="text-sm font-medium"
|
||||
/>
|
||||
</ClickableEventTitle>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.kind === 30384) {
|
||||
const parts = subject.split(":");
|
||||
const display =
|
||||
parts.length >= 3
|
||||
? `${parts[0]}:${parts[1].slice(0, 8)}...:${parts[2] || "*"}`
|
||||
: subject;
|
||||
return (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-sm font-semibold font-mono text-foreground"
|
||||
>
|
||||
{display}
|
||||
</ClickableEventTitle>
|
||||
);
|
||||
}
|
||||
|
||||
// Event ID (30383)
|
||||
return (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-sm font-semibold font-mono text-foreground"
|
||||
>
|
||||
{subject.slice(0, 16)}...
|
||||
</ClickableEventTitle>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact metrics preview — shows rank + top metrics
|
||||
*/
|
||||
function MetricsPreview({
|
||||
event,
|
||||
}: {
|
||||
event: { kind: number } & BaseEventProps["event"];
|
||||
}) {
|
||||
const tags = getAssertionTags(event);
|
||||
const rankTag = tags.find((t) => t.name === "rank");
|
||||
|
||||
// Collect metric type labels for the tag row
|
||||
const metricLabels = tags
|
||||
.filter((t) => t.name !== "rank" && t.name !== "t")
|
||||
.map((t) => ASSERTION_TAG_LABELS[t.name] || t.name);
|
||||
|
||||
let summaryMetrics: { label: string; value: string; unit?: string }[] = [];
|
||||
|
||||
if (event.kind === 30382) {
|
||||
const data = getUserAssertionData(event);
|
||||
if (data.followers !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Followers",
|
||||
value: data.followers.toLocaleString(),
|
||||
});
|
||||
if (data.postCount !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Posts",
|
||||
value: data.postCount.toLocaleString(),
|
||||
});
|
||||
if (data.zapAmountReceived !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Zaps In",
|
||||
value: data.zapAmountReceived.toLocaleString(),
|
||||
unit: "sats",
|
||||
});
|
||||
} else if (event.kind === 30383 || event.kind === 30384) {
|
||||
const data = getEventAssertionData(event);
|
||||
if (data.reactionCount !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Reactions",
|
||||
value: data.reactionCount.toLocaleString(),
|
||||
});
|
||||
if (data.commentCount !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Comments",
|
||||
value: data.commentCount.toLocaleString(),
|
||||
});
|
||||
if (data.zapAmount !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Zaps",
|
||||
value: data.zapAmount.toLocaleString(),
|
||||
unit: "sats",
|
||||
});
|
||||
} else if (event.kind === 30385) {
|
||||
const data = getExternalAssertionData(event);
|
||||
if (data.reactionCount !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Reactions",
|
||||
value: data.reactionCount.toLocaleString(),
|
||||
});
|
||||
if (data.commentCount !== undefined)
|
||||
summaryMetrics.push({
|
||||
label: "Comments",
|
||||
value: data.commentCount.toLocaleString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to raw tags if no structured data
|
||||
if (summaryMetrics.length === 0) {
|
||||
summaryMetrics = tags
|
||||
.filter((t) => t.name !== "rank" && t.name !== "t")
|
||||
.slice(0, 3)
|
||||
.map((t) => ({
|
||||
label: ASSERTION_TAG_LABELS[t.name] || t.name,
|
||||
value: t.value,
|
||||
}));
|
||||
} else {
|
||||
summaryMetrics = summaryMetrics.slice(0, 3);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Rank bar */}
|
||||
{rankTag && <RankBar rank={parseInt(rankTag.value, 10)} />}
|
||||
|
||||
{/* Metric type labels */}
|
||||
{metricLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{metricLabels.map((l) => (
|
||||
<Label key={l}>{l}</Label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary metrics */}
|
||||
{summaryMetrics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{summaryMetrics.map((m) => (
|
||||
<span key={m.label} className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{m.value}</span>
|
||||
{m.unit && (
|
||||
<span className="text-muted-foreground"> {m.unit}</span>
|
||||
)}{" "}
|
||||
{m.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted Assertion Renderer — Feed View (Kinds 30382-30385)
|
||||
* Shared renderer for all four NIP-85 assertion event kinds
|
||||
*/
|
||||
export function TrustedAssertionRenderer({ event }: BaseEventProps) {
|
||||
const subject = getAssertionSubject(event);
|
||||
const kindLabel = ASSERTION_KIND_LABELS[event.kind] || "Assertion";
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Kind label + subject as title */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="w-fit">{kindLabel}</Label>
|
||||
{subject && <SubjectTitle event={event} subject={subject} />}
|
||||
</div>
|
||||
|
||||
{/* Metrics preview */}
|
||||
<MetricsPreview event={event} />
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { UserName } from "../UserName";
|
||||
import { RelayLink } from "../RelayLink";
|
||||
import {
|
||||
getTrustedProviders,
|
||||
hasEncryptedProviders,
|
||||
formatKindTag,
|
||||
} from "@/lib/nip85-helpers";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Shield, Lock } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Trusted Provider List Detail Renderer (Kind 10040)
|
||||
* Stacked card layout for each provider entry — works at any panel width
|
||||
*/
|
||||
export function TrustedProviderListDetailRenderer({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const providers = getTrustedProviders(event);
|
||||
const hasEncrypted = hasEncryptedProviders(event);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-6 max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Trusted Providers</h2>
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="text-muted-foreground">Declared by:</span>
|
||||
<UserName pubkey={event.pubkey} className="font-medium" />
|
||||
</div>
|
||||
|
||||
{/* Encrypted notice */}
|
||||
{hasEncrypted && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-muted/50 text-sm">
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
This list contains encrypted provider entries (NIP-44) that cannot
|
||||
be displayed.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider cards */}
|
||||
{providers.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((p, i) => (
|
||||
<div
|
||||
key={`${p.servicePubkey}-${i}`}
|
||||
className="flex flex-col gap-2 p-3 rounded-md border border-border/50 bg-muted/30"
|
||||
>
|
||||
{/* Provider name */}
|
||||
<UserName
|
||||
pubkey={p.servicePubkey}
|
||||
relayHints={[p.relay]}
|
||||
className="text-sm font-medium"
|
||||
/>
|
||||
|
||||
{/* Kind tag */}
|
||||
<Label className="w-fit">{formatKindTag(p.kindTag)}</Label>
|
||||
|
||||
{/* Relay */}
|
||||
<RelayLink url={p.relay} showInboxOutbox={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
No public provider entries found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/components/nostr/kinds/TrustedProviderListRenderer.tsx
Normal file
88
src/components/nostr/kinds/TrustedProviderListRenderer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
BaseEventProps,
|
||||
BaseEventContainer,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UserName } from "../UserName";
|
||||
import { RelayLink } from "../RelayLink";
|
||||
import {
|
||||
getTrustedProviders,
|
||||
hasEncryptedProviders,
|
||||
formatKindTag,
|
||||
} from "@/lib/nip85-helpers";
|
||||
import { Shield, Lock } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Trusted Provider List Renderer — Feed View (Kind 10040)
|
||||
* Shows the user's declared trusted assertion providers
|
||||
*/
|
||||
export function TrustedProviderListRenderer({ event }: BaseEventProps) {
|
||||
const providers = getTrustedProviders(event);
|
||||
const hasEncrypted = hasEncryptedProviders(event);
|
||||
const previewProviders = providers.slice(0, 3);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ClickableEventTitle event={event} className="text-base font-semibold">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Shield className="size-4 text-muted-foreground" />
|
||||
Trusted Providers
|
||||
</span>
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Compact summary */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Label>
|
||||
{providers.length} provider{providers.length !== 1 ? "s" : ""}
|
||||
</Label>
|
||||
{hasEncrypted && (
|
||||
<Label className="flex items-center gap-1">
|
||||
<Lock className="size-3" />
|
||||
Encrypted
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider preview */}
|
||||
{previewProviders.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{previewProviders.map((p, i) => (
|
||||
<div
|
||||
key={`${p.servicePubkey}-${i}`}
|
||||
className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<UserName
|
||||
pubkey={p.servicePubkey}
|
||||
relayHints={[p.relay]}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label>{formatKindTag(p.kindTag)}</Label>
|
||||
<span className="text-muted-foreground/50">on</span>
|
||||
<RelayLink
|
||||
url={p.relay}
|
||||
showInboxOutbox={false}
|
||||
className="inline-flex"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{providers.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{providers.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All-encrypted fallback */}
|
||||
{hasEncrypted && providers.length === 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock className="size-3" />
|
||||
<span>All provider entries are encrypted</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -161,6 +161,10 @@ import { PollDetailRenderer } from "./PollDetailRenderer";
|
||||
import { PollResponseRenderer } from "./PollResponseRenderer";
|
||||
import { ReportRenderer, ReportDetailRenderer } from "./ReportRenderer";
|
||||
import { ThreadRenderer } from "./ThreadRenderer";
|
||||
import { TrustedAssertionRenderer } from "./TrustedAssertionRenderer";
|
||||
import { TrustedAssertionDetailRenderer } from "./TrustedAssertionDetailRenderer";
|
||||
import { TrustedProviderListRenderer } from "./TrustedProviderListRenderer";
|
||||
import { TrustedProviderListDetailRenderer } from "./TrustedProviderListDetailRenderer";
|
||||
|
||||
/**
|
||||
* Registry of kind-specific renderers
|
||||
@@ -215,6 +219,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10015: InterestListRenderer, // Interest List (NIP-51)
|
||||
10020: MediaFollowListRenderer, // Media Follow List (NIP-51)
|
||||
10030: EmojiListRenderer, // User Emoji List (NIP-51)
|
||||
10040: TrustedProviderListRenderer, // Trusted Provider List (NIP-85)
|
||||
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
|
||||
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
|
||||
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
|
||||
@@ -238,6 +243,10 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66)
|
||||
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
|
||||
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
|
||||
30382: TrustedAssertionRenderer, // User Assertion (NIP-85)
|
||||
30383: TrustedAssertionRenderer, // Event Assertion (NIP-85)
|
||||
30384: TrustedAssertionRenderer, // Address Assertion (NIP-85)
|
||||
30385: TrustedAssertionRenderer, // External Assertion (NIP-85)
|
||||
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
|
||||
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
|
||||
30617: RepositoryRenderer, // Repository (NIP-34)
|
||||
@@ -321,6 +330,7 @@ const detailRenderers: Record<
|
||||
10004: CommunityListDetailRenderer, // Community List Detail (NIP-51)
|
||||
10005: ChannelListDetailRenderer, // Channel List Detail (NIP-51)
|
||||
10015: InterestListDetailRenderer, // Interest List Detail (NIP-51)
|
||||
10040: TrustedProviderListDetailRenderer, // Trusted Provider List Detail (NIP-85)
|
||||
10020: MediaFollowListDetailRenderer, // Media Follow List Detail (NIP-51)
|
||||
10030: EmojiListDetailRenderer, // User Emoji List Detail (NIP-51)
|
||||
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
|
||||
@@ -344,6 +354,10 @@ const detailRenderers: Record<
|
||||
30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66)
|
||||
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
|
||||
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
|
||||
30382: TrustedAssertionDetailRenderer, // User Assertion Detail (NIP-85)
|
||||
30383: TrustedAssertionDetailRenderer, // Event Assertion Detail (NIP-85)
|
||||
30384: TrustedAssertionDetailRenderer, // Address Assertion Detail (NIP-85)
|
||||
30385: TrustedAssertionDetailRenderer, // External Assertion Detail (NIP-85)
|
||||
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
|
||||
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
|
||||
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
|
||||
|
||||
@@ -5,8 +5,10 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -16,7 +18,10 @@ const Progress = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
className={cn(
|
||||
"h-full w-full flex-1 bg-primary transition-all",
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
|
||||
Reference in New Issue
Block a user