feat: timestamps and user locale

This commit is contained in:
Alejandro Gómez
2025-12-10 22:32:36 +01:00
parent b2e269a902
commit 8ffd0fd2cb
20 changed files with 183 additions and 63 deletions

View File

@@ -175,7 +175,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
) : event.kind === 9802 ? (
<Kind9802DetailRenderer event={event} />
) : (
<KindRenderer event={event} showTimestamp={true} />
<KindRenderer event={event} />
)}
</div>
</div>

View File

@@ -58,7 +58,7 @@ export function JsonViewer({
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto mt-2">
<pre className="text-xs font-mono bg-muted p-4 rounded-lg">
<pre className="text-xs font-mono bg-muted p-4 overflow-scroll">
{jsonString}
</pre>
</div>

View File

@@ -13,13 +13,13 @@ import {
import { Menu, Copy, FileJson, ExternalLink } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { JsonViewer } from "@/components/JsonViewer";
import { formatTimestamp } from "@/hooks/useLocale";
/**
* Universal event properties and utilities shared across all kind renderers
*/
export interface BaseEventProps {
event: NostrEvent;
showTimestamp?: boolean;
depth?: number;
}
@@ -126,35 +126,37 @@ export function EventMenu({ event }: { event: NostrEvent }) {
* Base event container with universal header
* Kind-specific renderers can wrap their content with this
*/
/**
* Format relative time (e.g., "2m ago", "3h ago", "5d ago")
*/
export function BaseEventContainer({
event,
children,
showTimestamp = false,
}: {
event: NostrEvent;
children: React.ReactNode;
showTimestamp?: boolean;
}) {
// Format timestamp
const timestamp = new Date(event.created_at * 1000).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
// Format relative time for display
const { locale } = useGrimoire();
const relativeTime = formatTimestamp(event.created_at, "relative", locale.locale);
// Format absolute timestamp for hover (ISO-8601 style)
const absoluteTime = formatTimestamp(event.created_at, "absolute", locale.locale);
return (
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
<div className="flex flex-row justify-between items-center">
<EventAuthor pubkey={event.pubkey} />
{showTimestamp ? (
<span className="text-xs text-muted-foreground font-mono">
{timestamp}
<div className="flex flex-row gap-2 items-baseline">
<EventAuthor pubkey={event.pubkey} />
<span
className="text-xs font-light text-muted-foreground cursor-help"
title={absoluteTime}
>
{relativeTime}
</span>
) : (
<EventMenu event={event} />
)}
</div>
<EventMenu event={event} />
</div>
{children}
</div>

View File

@@ -8,7 +8,7 @@ import { RichText } from "../RichText";
* Renderer for Kind 0 - Profile Metadata
* Displays as a compact profile card in feed view
*/
export function Kind0Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind0Renderer({ event }: BaseEventProps) {
const pubkey = event.pubkey;
const profile = useProfile(pubkey);
@@ -16,7 +16,7 @@ export function Kind0Renderer({ event, showTimestamp }: BaseEventProps) {
const website = profile?.website;
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-3">
{/* Profile Info */}
<div className="flex flex-col gap-2 p-3 border border-muted bg-muted/20">

View File

@@ -14,7 +14,7 @@ import { FileText, Download } from "lucide-react";
* Renderer for Kind 1063 - File Metadata (NIP-94)
* Displays file metadata with appropriate preview for images, videos, and audio
*/
export function Kind1063Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind1063Renderer({ event }: BaseEventProps) {
const metadata = parseFileMetadata(event);
// Determine file type from MIME
@@ -29,7 +29,7 @@ export function Kind1063Renderer({ event, showTimestamp }: BaseEventProps) {
event.tags.find((t) => t[0] === "summary")?.[1] || event.content;
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-3">
{/* File preview */}
{metadata.url && (isImage || isVideo || isAudio) ? (

View File

@@ -9,24 +9,16 @@ import { useGrimoire } from "@/core/state";
/**
* Renderer for Kind 1 - Short Text Note
*/
export function Kind1Renderer({
event,
showTimestamp,
depth = 0,
}: BaseEventProps) {
export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
const { addWindow } = useGrimoire();
const refs = getNip10References(event);
const hasReply = refs.reply?.e || refs.reply?.a;
// Fetch parent event if replying
const parentEvent = useNostrEvent(
hasReply ? refs.reply?.e || refs.reply?.a : undefined,
);
const pointer =
refs.reply?.e || refs.reply?.a || refs.root?.e || refs.root?.a;
const parentEvent = useNostrEvent(pointer);
const handleReplyClick = () => {
if (!parentEvent) return;
const pointer = refs.reply?.e || refs.reply?.a;
if (pointer) {
addWindow(
"open",
@@ -37,8 +29,8 @@ export function Kind1Renderer({
};
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
{hasReply && parentEvent && (
<BaseEventContainer event={event}>
{pointer && parentEvent && (
<div
onClick={handleReplyClick}
className="flex items-start gap-2 p-1 bg-muted/20 text-xs text-muted-foreground hover:bg-muted/30 cursor-pointer rounded transition-colors"

View File

@@ -7,7 +7,7 @@ import { parseImetaTags } from "@/lib/imeta";
* Renderer for Kind 20 - Picture Event (NIP-68)
* Picture-first feed events with imeta tags for image metadata
*/
export function Kind20Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind20Renderer({ event }: BaseEventProps) {
// Parse imeta tags to get image URLs and metadata
const images = parseImetaTags(event);
@@ -15,7 +15,7 @@ export function Kind20Renderer({ event, showTimestamp }: BaseEventProps) {
const title = event.tags.find((t) => t[0] === "title")?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-2">
{/* Title if present */}
{title && (

View File

@@ -7,7 +7,7 @@ import { parseImetaTags } from "@/lib/imeta";
* Renderer for Kind 21 - Video Event (NIP-71)
* Horizontal/landscape video events with imeta tags
*/
export function Kind21Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind21Renderer({ event }: BaseEventProps) {
// Parse imeta tags to get video URLs and metadata
const videos = parseImetaTags(event);
@@ -15,7 +15,7 @@ export function Kind21Renderer({ event, showTimestamp }: BaseEventProps) {
const title = event.tags.find((t) => t[0] === "title")?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-2">
{/* Title if present */}
{title && <h3 className="text-base font-semibold">{title}</h3>}

View File

@@ -7,7 +7,7 @@ import { parseImetaTags } from "@/lib/imeta";
* Renderer for Kind 22 - Short Video Event (NIP-71)
* Short-form portrait video events (like TikTok/Reels)
*/
export function Kind22Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind22Renderer({ event }: BaseEventProps) {
// Parse imeta tags to get video URLs and metadata
const videos = parseImetaTags(event);
@@ -15,7 +15,7 @@ export function Kind22Renderer({ event, showTimestamp }: BaseEventProps) {
const title = event.tags.find((t) => t[0] === "title")?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-2">
{/* Title if present */}
{title && <h3 className="text-base font-semibold">{title}</h3>}

View File

@@ -9,12 +9,12 @@ import {
* Renderer for Kind 30023 - Long-form Article
* Displays article title and summary in feed
*/
export function Kind30023Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind30023Renderer({ event }: BaseEventProps) {
const title = useMemo(() => getArticleTitle(event), [event]);
const summary = useMemo(() => getArticleSummary(event), [event]);
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-2">
{/* Title */}
{title && (

View File

@@ -7,7 +7,7 @@ import { Users, Sparkles } from "lucide-react";
* Kind 3 Renderer - Contact/Follow List
* Shows follow count and "follows you" indicator
*/
export function Kind3Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind3Renderer({ event }: BaseEventProps) {
const { state } = useGrimoire();
// Extract followed pubkeys from p tags
@@ -20,7 +20,7 @@ export function Kind3Renderer({ event, showTimestamp }: BaseEventProps) {
: false;
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-2 text-xs">
<span className="flex items-center gap-1">
<Users className="size-3 text-muted-foreground" />

View File

@@ -7,7 +7,7 @@ import { useGrimoire } from "@/core/state";
* Renderer for Kind 6 - Reposts
* Displays repost indicator with the original event embedded
*/
export function Kind6Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind6Renderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
// Get the event being reposted (e tag)
@@ -15,7 +15,7 @@ export function Kind6Renderer({ event, showTimestamp }: BaseEventProps) {
const repostedEventId = eTag?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Repeat2 className="size-4" />

View File

@@ -10,7 +10,7 @@ import { KindRenderer } from "./index";
* Displays emoji/reaction with the event being reacted to
* Supports both e tags (event ID) and a tags (address/replaceable events)
*/
export function Kind7Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind7Renderer({ event }: BaseEventProps) {
// Get the reaction content (usually an emoji)
const reaction = event.content || "❤️";
@@ -108,7 +108,7 @@ export function Kind7Renderer({ event, showTimestamp }: BaseEventProps) {
};
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-2">
{/* Reaction indicator */}
<div className="flex items-center gap-2">

View File

@@ -18,7 +18,7 @@ import { RichText } from "../RichText";
* Renderer for Kind 9735 - Zap Receipts
* Displays zap amount, sender, and zapped content
*/
export function Kind9735Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind9735Renderer({ event }: BaseEventProps) {
// Validate zap
const isValid = useMemo(() => isValidZap(event), [event]);
@@ -49,7 +49,7 @@ export function Kind9735Renderer({ event, showTimestamp }: BaseEventProps) {
if (!isValid) {
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="text-xs text-muted-foreground">Invalid zap receipt</div>
</BaseEventContainer>
);
@@ -65,7 +65,7 @@ export function Kind9735Renderer({ event, showTimestamp }: BaseEventProps) {
);
return (
<BaseEventContainer event={displayEvent} showTimestamp={showTimestamp}>
<BaseEventContainer event={displayEvent} >
<div className="flex flex-col gap-2">
{/* Zap indicator */}
<div className="flex items-center gap-2">

View File

@@ -11,13 +11,13 @@ import {
* Renderer for Kind 9802 - Highlight
* Displays highlighted text with optional comment and source URL
*/
export function Kind9802Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind9802Renderer({ event }: BaseEventProps) {
const highlightText = useMemo(() => getHighlightText(event), [event]);
const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]);
const comment = useMemo(() => getHighlightComment(event), [event]);
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event} >
<div className="flex flex-col gap-2">
{/* Comment */}
{comment && <p className="text-sm text-foreground">{comment}</p>}

View File

@@ -37,9 +37,9 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
* Default renderer for kinds without custom implementations
* Shows basic event info with raw content
*/
function DefaultKindRenderer({ event, showTimestamp }: BaseEventProps) {
function DefaultKindRenderer({ event }: BaseEventProps) {
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<BaseEventContainer event={event}>
<div className="text-sm text-muted-foreground">
<div className="text-xs mb-1">Kind {event.kind} event</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
@@ -56,15 +56,13 @@ function DefaultKindRenderer({ event, showTimestamp }: BaseEventProps) {
*/
export function KindRenderer({
event,
showTimestamp = false,
depth = 0,
}: {
event: NostrEvent;
showTimestamp?: boolean;
depth?: number;
}) {
const Renderer = kindRenderers[event.kind] || DefaultKindRenderer;
return <Renderer event={event} showTimestamp={showTimestamp} depth={depth} />;
return <Renderer event={event} depth={depth} />;
}
/**